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 001/268] [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 b5f3019a12..76fbadd196 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 2cfcf111ea..fe99017774 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 fcdc6d1d19..e1273587c4 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 0000000000..e69de29bb2 diff --git a/tests/component_tests/it8951/test_init.py b/tests/component_tests/it8951/test_init.py new file mode 100644 index 0000000000..46c203e5eb --- /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 002/268] [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 a851e62a3e..4264479349 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 8caf020212..5a6a01f669 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 b51d4cb795..6a23836c8a 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 0000000000..e69de29bb2 diff --git a/tests/component_tests/teleinfo/test_sensor.py b/tests/component_tests/teleinfo/test_sensor.py new file mode 100644 index 0000000000..a8f9420dea --- /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 dcb2cb14bd..7f73bca62c 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 003/268] [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 af3fda6349..871e74d36d 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 2a799a4d84..c73858ed52 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 004/268] [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 44369136a0..b0ba766392 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 f1e53e1ccb..3a8587891b 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 a2ee244a29..c74262fdee 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 2ea4ffabe6..4c60b4e476 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 9e40698c91..c3936ee65a 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 e7f9b96af2..d51110efad 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 005/268] [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 7cd0c6cdc1..de9dc6401e 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 006/268] [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 586263c651..285d6869e1 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 c955391a61..85e4df38f1 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 007/268] [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 16b5f544f7..89a6ff4234 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 fb22a26b55..70befc9049 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 008/268] [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 43b7e6663d..7507eb4a42 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 009/268] [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 440a23e9c9..018e30a9a8 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 010/268] [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 0000000000..4203c76a50 --- /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 0000000000..08ddd3f007 --- /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 011/268] [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 b54d797f09..6b1ffc5473 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 053f17765e..4daeef5059 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 012/268] [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 2d38351898..3743164896 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 dabd62b8f1..8ee4bda042 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 269d39b06b..cf943389e1 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 4a2deda163..8335ea3543 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 013/268] [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 3743164896..1fbe1d67cf 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 8ee4bda042..515cd81fdf 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 cf943389e1..493661f9a6 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 8335ea3543..541ba4f558 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 014/268] [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 a25a753f75..edfcd0f730 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 e381db7b5c..1066a5c44d 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 c3939735a4..fa179f9667 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 ed2fd67007..eb2fde3a49 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 015/268] [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 aa4f78de69..9bceaf5676 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 4b7a0a6ab8..9072a90629 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 016/268] [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 02c0bd2228..56308e3298 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 6b1ffc5473..e93d508a3d 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 b41575c717..8ef5971f17 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 79fb81ea5f..d5e15f7c62 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 017/268] =?UTF-8?q?[tca9548a]=20Allow=20separate=20I=C2=B2?= =?UTF-8?q?C=20bus=20frequencies=20on=20each=20multiplexer=20port=20(#1042?= =?UTF-8?q?2)?= 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 0c5e80bfe5..be38317497 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 95aee2bddb..215cc1364f 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 71e91e770b..9feea9f7ff 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 b13b525abb..931bcfa088 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 92e96f649b..b9d8824d25 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 8064016a52..086baab26c 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 4145cd5859..ee6e66893e 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 3ada1e0a0f..413d78d6a3 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 a1945e97c5..368e34f325 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 f10578c97c..ac33ca8ded 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 a98c226d32..5b0d8a0a76 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 731c0d2017..d4aca85962 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 bfaf1e26fb..eea1b9964d 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 0000000000..a664179137 --- /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 018/268] [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 de9dc6401e..f2a7825997 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 f74a49ea89..5edcddfd32 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 cafeda6478..9115e21874 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 0000000000..c0b5e202f7 --- /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 019/268] [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 e93d508a3d..eb999a05ff 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 020/268] 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 9edbd6e4f9..c9aa3dba62 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 021/268] [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 0a35cb4fee..f8a9673960 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 022/268] [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 0dc3b4b6f7..d01eddb9ff 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 86cac665b4..aad2b5c0e3 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 bf8e8287c4..7177b9fc79 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 89417e748d..7c197133c2 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 093ae95939..28ea64c8ce 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 103e901b13..68136d5719 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 3f863140e6..8197796b6d 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 3d19180c06..84b79bb84f 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 023/268] [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 51bda0c75d..23dc41d509 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 828eb77d1c..88eed2246f 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 8685c026b0..432eb77e13 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 57ee25bfc1..14e0d6bf5a 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 024/268] [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 032d01c8e5..9fbda9a973 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 fb6c3929b0..8fa56bd370 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 3e4600acd2..b641f6e716 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 d4aca85962..0e5e2f4f29 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 f93d54e8b6..a892aa614f 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 025/268] [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 905339d2bc..77c1c9f1ad 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 93cf588e02..711dd9e815 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 28a688f15a..682557ecfc 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 79d7068838..64d22f9a14 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 7774722795..115ccad747 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 1668263e33..61092ec427 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 904963ba4e..a31f3252ce 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 ba7b4e727b..e4ff0bb6ea 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 0000000000..395253dd70 --- /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 37d8e7486f..3b8330f159 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 7d401eaa65..178fd84020 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 026/268] [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 3c23517b92..6c491379c8 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 eb999a05ff..7160ef5f3a 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 9235971402..ad51d5f39b 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 027/268] [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 7160ef5f3a..51a5e01210 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 af1793450c..53427fc63e 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 7fdfd90bb6..c6239ef6d2 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 028/268] [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 e5f8544f2f..962e6449f4 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 f9f09a98bc..c75496ad4f 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 a853730f31..f291077e52 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 0000000000..8a083978ec --- /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 0000000000..ad4129e497 --- /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 0000000000..8cfd65301c --- /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 0000000000..cc4956ffee --- /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 0000000000..4664f0913b --- /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 0000000000..528af88854 --- /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 029/268] [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 962e6449f4..69fbb21030 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 c75496ad4f..57361a4a58 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 f291077e52..799f690f54 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 ba96c0628d..2b37e98946 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 030/268] [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 aef8fd17b5..e5abb16b1c 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 851b8345c9..88e240a646 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 375bd4faef..b912233eed 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 ceda1b7683..8db38b800e 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 fb125864fb..d9f4a73f93 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 0000000000..bc19f34a7c --- /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 0000000000..b45c09b072 --- /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 0000000000..736e878837 --- /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 0000000000..4b9c65fecf --- /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 7053229db4..93bc181a18 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 51069b6415..79938f8df2 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 b53b8064a3..77b47228a9 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 0000000000..a97a5ef8ae --- /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 973781fb68..54b089116f 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 0000000000..c45547cd53 --- /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 b47e39c389..1b3ba941b6 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 0000000000..ea7d1f197e --- /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 21305c2b33..762e660139 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 031/268] [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 cc75eb70b8..74b5bcedd9 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 08d068d0f0..823928176a 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 674e23cb34..226b59a291 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 dbadc56c9d..122e8960fb 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 9855fcb4e6..1a08549a2f 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 032/268] [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 7a1310b9ac..0194b34db0 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 b7fa992307..7f8638101d 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 0000000000..c0e2447832 --- /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 0000000000..640cc34b13 --- /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 fd73841686..acca04faf4 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 0000000000..fd695b9ca4 --- /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 0000000000..5a71cf41b7 --- /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 0000000000..62ac9ac7ab --- /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 4bed2f03e5..6d9ed4c436 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 033/268] [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 bd43d06cc2..c6137f29f7 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 a52402c2ea..c427d024f3 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 a0a525a810..1af7b1e7f2 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 0000000000..ae3c2e45d5 --- /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 0000000000..82a3c01de7 --- /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 0000000000..9950e78042 --- /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 ea2ec38b2a..e9a69ca870 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 034/268] [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 aa0ceb5abe..f1ceb732f9 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 035/268] [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 871e74d36d..698080eebc 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 c73858ed52..3cba1a7357 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 036/268] [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 0194b34db0..8b8d16677a 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 7f8638101d..3124ba0145 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 037/268] [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 6b68dacd31..adf5670784 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 038/268] [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 bf392c7826..c05db51cdf 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 b23dcc946b..c6316f59fc 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 f074d30162..1e2e12200d 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 0000000000..e69de29bb2 diff --git a/tests/component_tests/tuya/test_init.py b/tests/component_tests/tuya/test_init.py new file mode 100644 index 0000000000..6984b9aa1b --- /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 0000000000..df0fe110b4 --- /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 039/268] [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 ae92b73731..17497984c3 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 040/268] [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 17497984c3..675744b5f5 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 c56005e7ad..f97c5aa6cb 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 0000000000..4af5a98572 --- /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 0000000000..373b54d785 --- /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 041/268] [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 9bceaf5676..422413a269 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 ad51d5f39b..a2c181a5ae 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 3a70be21ca..983a4777e8 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 9072a90629..bb751dd677 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 042/268] [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 33d32f4c07..d2e1603bd1 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 043/268] [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 373b54d785..20d3adf0dc 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 044/268] [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 80065546cf..a79a7c3bd8 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 298cd9f527..011813a20d 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 3bdfa094e0..1e351b4971 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 045/268] [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 0262f8681f..971f976924 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 0000000000..c190c14ab1 --- /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 046/268] [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 49ac214d40..ae44017112 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 3def5fba12..e4aa0684c7 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 99ca31590f..b46f769db0 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 0000000000..4b5cb52b08 --- /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 0000000000..cba3caa8a6 --- /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 0000000000..f0c12b5dce --- /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 0e5e2f4f29..de621f891a 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 ea97e2d840..b4ae2410fd 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 047/268] [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 8194cd5040..6bdd9467c0 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 048/268] [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 1553f33618..e18325303c 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 b0b9bb1c17..0e2da59ab1 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 a8b470de7e..ac1386df03 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 7d0eff8c9d..7da8dcca54 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 695b7f33d6..c4073d2782 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 9276901156..0bf077b746 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 8b8d16677a..370c04fbf6 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 e79135b185..fa01df73d8 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 5d8ef52dc0..cfb3521a66 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 a486037266..04ef798b69 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 eec4d7f55b..3ed794daea 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 81d6647642..b5e1c98bcb 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 9a928dac01..af411d133a 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 bfed99b21b..ddfe77d3ac 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 1045467866..906781e17b 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 5be6da88ef..0f623bfd72 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 515cd81fdf..9fba222d63 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 c6c7b57c5f..3261d9d569 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 eecaa1a1d4..b759758019 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 ebd508b535..2f57c02843 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 1687edd511..5326aeb145 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 e86153be86..34c6a3e44f 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 7809e803c9..606a03e0c2 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 3d46f0613e..147365251b 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 31c8e89af3..1bc3e0aeb4 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 0660e0f20e..a7959a0999 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 02282fef9c..c72210e932 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 4c537b75cb..6c780d07a9 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 5e7f0ef29c..15111338b2 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 adf5670784..91a0d0e16f 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 57361a4a58..406a1bc55d 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 c6316f59fc..025b97ee72 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 cd4388372c..c68842ab6c 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 2888fc0abb..b7b9e5cfa9 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 a2ede27341..c54d1bc16f 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 521ae10e5c..d0058a634b 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 049/268] [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 6b6af4ed72..0105d1e107 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 3cd70c082d..351afe541d 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 050/268] [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 8510f18769..6fd5f481e7 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 eba0d3c026..fbfed7444d 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 d851bd925f..a1066b9761 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 f9eda32e98..b14b6c4cc1 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 de621f891a..31549ceb8e 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 0000000000..61db1769a2 --- /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 0000000000..5d32255cb8 --- /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 0000000000..6326b0eaa2 --- /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 0000000000..020303d455 --- /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 0000000000..9702134e8c --- /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 049a8067da..c764eae42b 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 b47e39c389..d879c5b4ae 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 4a98b9388a..10be01238c 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 319a7c71a6..b364443f57 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 0000000000..b148b07716 --- /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 051/268] [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 062007b02e..323857c37e 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 052/268] [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 23dc41d509..56733870dc 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 88eed2246f..dfe3ab9fd3 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 432eb77e13..077ac9d4cd 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 2272459fb8..2713bae241 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 083c97f8cf..b98ba853e7 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 f224e10be3..f9549bea34 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 8bb8918f96..eab9be0f70 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 8a1973d022..599a864e54 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 960e150800..8d2fe4d51b 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 f15b609d84..a26c5d35a3 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 2e88f3e5c6..a35839d9f2 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 053/268] [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 971f976924..79b0e83405 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 054/268] [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 5294f20f2d..67c3cd75bd 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 055/268] [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 eb05f7d21e..3263f6b220 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 57706a7e4f..f6064dd5df 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 3c9ba7b61f..e7fa563fb5 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 90af88d2c7..770784b0fc 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 c2a487080b..23c6bd21e8 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 958127ba66..923d412e84 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 714f8ab571..c52eb41ca4 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 0000000000..bffea25864 --- /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 056/268] [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 1cd75ddaf0..0b871af30c 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 49cd2fc7c6..0bd8346953 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 74b5bcedd9..7d1053f4fa 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 31549ceb8e..552221dd5e 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 057/268] [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 e1fcef107d..1846958e59 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 0000000000..dcb5d3294c --- /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 c68842ab6c..47ff2165f8 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 0a39dcc9af..cf73daa5fa 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 552221dd5e..730e17fb53 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 0000000000..19febacc61 --- /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 0000000000..f78cdfe033 --- /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 1582358700..e04e09b765 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 c818dc2965..29bc05dfab 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 34041cc223..a2d4469798 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 195f4b41b5..7826cdc44a 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 13b3284fe3..1483674ddc 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 0000000000..a1d10b1926 --- /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 0000000000..5898b56563 --- /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 3b1b148d50..8e659ce40d 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 6b7feb0f1b..c6d5a7f4eb 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 0000000000..19c0739f0b --- /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 0000000000..0f72225c08 --- /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 0000000000..c2c2f08f52 --- /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 058/268] [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 0105d1e107..fea5769050 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 351afe541d..f2d4628044 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 059/268] [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 91a0d0e16f..b4e474dea4 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 b1bf586e9c..39f0287411 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 060/268] [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 b4e474dea4..1d3813d59e 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 061/268] [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 b759758019..98dd0b74ec 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 1a6af4b1b3..061a9a22e9 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 84106eb0b7..a2fb33a2a3 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 062/268] [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 14b1f60df5..f99e5adaa2 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 c62960e7c3..c123a42b9e 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 5eac5415ed..723b99a504 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 0000000000..538b2481a4 --- /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 b415125e84..9e5b478a31 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 96ab4ef6ac..8e7bd3ebe8 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 b28f2b5e05..58779cc095 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 0000000000..c0c648e038 --- /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 063/268] [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 f6d1daa124..89002cdd19 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 9d7dedbbbf..d1a70c5aa8 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 0000000000..6075623b54 --- /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 0000000000..31fe286a86 --- /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 064/268] [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 f47be4aea0..0e02f20080 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 c261629573..a85878e764 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 9bec3751d6..b48d32b900 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 065/268] [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 66aaac5301..9b2f41e063 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 d797688a7e..4e8698f33c 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 fc7317f8b9..e658735c75 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 c019dff96e..854ef0fb49 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 e35cc0c48b..0b375404d7 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 8b7397c4cd..4da8a6a1a3 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 924c17fb54..2d879e8d43 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 56dcf81a79..cd118595f1 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 d69e90056f..b48123b902 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 ac993cc382..4739412e09 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 2137acb3f6..9dd5e84419 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 f41cb8e4e9..98e1838f0f 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 fedc79383d..a49d58dc4d 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 29087009cb..44d16bf614 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 8ac76cd41f..670145cfe0 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 7534b748d1..7b3414a32a 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 0000000000..a5ccd0e68d --- /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 0000000000..769032f839 --- /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 0000000000..94950a3b43 --- /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 0000000000..969091c086 --- /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 368a3a4990..e93d858ad5 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 0000000000..8b93c01166 --- /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 c44b40317e..9ec73ca618 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 18a7fd0913..681eb41b5b 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 0000000000..e0332caaed --- /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 5fd9370255..a7a8d13ffc 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 22d051da8e..1df27d82f7 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 1c60e49604..5a668a2302 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 066/268] [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 ade93b8735..ea7ebaed0a 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 06c920a6e4..4695e39829 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 5a697157b7..02f9e239c7 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 cc7477395b..0986120dcf 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 819764a7ea..883b9d289d 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 067/268] [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 ba76053686..db915e9c7f 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 0000000000..e69de29bb2 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 0000000000..5f4927fe10 --- /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 926a1d1b7a..ab39dfa61d 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 068/268] [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 c4073d2782..a610181222 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 e08ebf09cc..557de0e342 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 5f79be0255..8ffbb44771 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 77b19957b4..ae3a642709 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 069/268] [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 79b0e83405..2146545db5 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 675744b5f5..a307ac0de2 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 f97c5aa6cb..83ee4fdbab 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 af8d44896b..294e7367fb 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 20d3adf0dc..8530b0a003 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 070/268] [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 914361a4ac..3ebaf53f5b 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 0000000000..bbb4a7269a --- /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 071/268] [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 1bc3e0aeb4..8d5103cbba 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 b231bab33e..23cecfc986 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 0000000000..3853bf3e8d --- /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 072/268] [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 b21dc2e2f0..826d4e32ef 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 0000000000..e69de29bb2 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 0000000000..a6bb84c7c7 --- /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 073/268] [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 d01eddb9ff..1c85553c57 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 aad2b5c0e3..109b953de5 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 7c197133c2..ffb0d7ec7a 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 28ea64c8ce..f5c7ff8de3 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 68136d5719..fec8ceba74 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 8197796b6d..d468c9881d 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 8ef4957072..4851ec7dea 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 84b79bb84f..9df43d957b 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 2da6570618..a99f6bd92e 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 074/268] [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 97b27af498..f00ad7f4af 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 d3f1fe59e5..35c2a17c24 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 8762cbe711..69def222da 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 0000000000..56cfa10d51 --- /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 0000000000..17f553996e --- /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 075/268] [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 1c85553c57..44f83696f2 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 b73c26a836..e2ff542976 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 305febf171..adaa60646d 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 88f49ef3c1..1616ce0ceb 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 ffb0d7ec7a..68c0d005b5 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 f5c7ff8de3..d5ed7439bd 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 fec8ceba74..99f075ecb2 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 9df43d957b..2b6fc3b6c5 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 076/268] [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 f04f784ebc..9477ebc850 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 6d76e39986..4ae66d69bf 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 03f4a86fd4..862d40733b 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 7cbd11711a..14e6046ba0 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 1cb9d6eba5..730afe32bf 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 730e17fb53..c4c20e80a6 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 9ac1056ba4..2b9df607c8 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 0000000000..bddab918c2 --- /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 a1b7b75423..8806d8046d 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 077/268] [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 0000000000..d80dbf1a85 --- /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 a32677b3da..b81c0af85f 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 0098e752b2..3d4a2ba85b 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 0000000000..b65e8a659f --- /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 078/268] [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 20f6385b6a..56b8e6401e 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 2543cb5476..6d446fd6a6 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 9e2f273f47..7fb704536d 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 5a77bf26d9..802a88ba80 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 2898576b32..581727b326 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 00c5e0b069..ed5a034b82 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 5551abe1a1..e920da6c26 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 fcebf0fbd9..e10da78aaf 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 db177fd308..36107c093c 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 86dfa6d729..f61642566c 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 c6ae0b4eef..4e52fb60e2 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 079/268] [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 a017109e7d..2ad3cd9c31 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 74f81df2e9..0572e591cf 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 7f0a5f979c..0de0ee0ee4 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 080/268] [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 d4ae82fd9f..1b06348e2c 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 10a5cbc70c..9467efcfa0 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 7d5a566b55..6bdc1654bc 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 3ab80a063e..8e5c808e24 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 c4c20e80a6..d219296ca5 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 112092482b..f55a6974e7 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 bd84c8d9bb..79feb398bd 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 5e1936a042..d658da1c1f 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 033c8b627e..54aeb4b4ed 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 081/268] [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 b81c0af85f..0819a1bfb0 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 cf569702c40e22a0440fc4a83fb0605fdd1209eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 15:42:45 -1000 Subject: [PATCH 082/268] [ci] Stop the toolchain tests from installing ESP-IDF (#20413) --- tests/unit_tests/conftest.py | 16 ++++++++++++++++ tests/unit_tests/test_espidf_toolchain.py | 2 ++ 2 files changed, 18 insertions(+) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index ff5564e815..7835156b01 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -18,6 +18,7 @@ from unittest.mock import Mock, patch import pytest from esphome.core import CORE +from esphome.espidf import toolchain here = Path(__file__).parent @@ -26,6 +27,21 @@ package_root = here.parent.parent sys.path.insert(0, package_root.as_posix()) +@pytest.fixture(autouse=True) +def _no_idf_install(monkeypatch: pytest.MonkeyPatch) -> None: + """Fail any test that would install ESP-IDF for real. + + An unmocked ``_get_idf_env`` reaches ``check_esp_idf_install``, which + downloads the framework on a bare CI runner (minutes on Windows). The + install path's own tests call it through ``esphome.espidf.framework``. + """ + + def refuse(*args: object, **kwargs: object) -> None: + raise AssertionError("test would install ESP-IDF; mock _get_idf_env") + + monkeypatch.setattr(toolchain, "check_esp_idf_install", refuse) + + @pytest.fixture(autouse=True) def reset_core(): """Reset CORE after each test.""" diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 620d08fe3b..854c39a9ce 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -1519,6 +1519,7 @@ def test_run_reconfigure_flip_into_skip_mode_cleans_up(setup_core: Path) -> None with ( patch.object(toolchain, "_skip_bootloader", return_value=True), patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"), + patch.object(toolchain, "_get_idf_env", return_value={}), patch.object(toolchain, "_tool_env", return_value={}), patch.object(toolchain, "run_build_tool", return_value=0), patch.object(toolchain, "_idf_py") as mock_idf_py, @@ -1542,6 +1543,7 @@ def test_run_reconfigure_skip_steady_state_cleans_nothing(setup_core: Path) -> N with ( patch.object(toolchain, "_skip_bootloader", return_value=True), patch.object(toolchain, "_get_idf_tool", side_effect=lambda n: f"/tools/{n}"), + patch.object(toolchain, "_get_idf_env", return_value={}), patch.object(toolchain, "_tool_env", return_value={}), patch.object(toolchain, "run_build_tool", return_value=0), patch.object(toolchain, "_idf_py") as mock_idf_py, From 8e6ed71e2a7a7f1399414eb584372e9837c25cca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:31:26 -1000 Subject: [PATCH 083/268] [ethernet] Collapse duplicate CI test variants (#20406) --- .../ethernet/common-w5500-spi-id.yaml | 17 -------- .../ethernet/test-ip101.esp32-idf.yaml | 7 +++- .../ethernet/test-ipv6.esp32-idf.yaml | 12 ------ .../ethernet/test-lifecycle.esp32-idf.yaml | 39 ------------------- .../ethernet/test-w5500-spi-id.esp32-idf.yaml | 3 -- .../test-w5500-wifi-psram.esp32-s3-idf.yaml | 15 ------- .../ethernet/test-w5500.esp32-idf.yaml | 37 +++++++++++++++--- .../ethernet/test.esp32-s3-idf.yaml | 16 +++++++- ...aml => validate-ksz8081rna.esp32-idf.yaml} | 0 ...date-lan8720-with-expander.esp32-idf.yaml} | 0 .../validate-w5500-interface.esp32-idf.yaml | 12 ++++++ 11 files changed, 65 insertions(+), 93 deletions(-) delete mode 100644 tests/components/ethernet/common-w5500-spi-id.yaml delete mode 100644 tests/components/ethernet/test-ipv6.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-lifecycle.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml delete mode 100644 tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml rename tests/components/ethernet/{test-ksz8081rna.esp32-idf.yaml => validate-ksz8081rna.esp32-idf.yaml} (100%) rename tests/components/ethernet/{test-lan8720-with-expander.esp32-idf.yaml => validate-lan8720-with-expander.esp32-idf.yaml} (100%) create mode 100644 tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml diff --git a/tests/components/ethernet/common-w5500-spi-id.yaml b/tests/components/ethernet/common-w5500-spi-id.yaml deleted file mode 100644 index 14c01842f0..0000000000 --- a/tests/components/ethernet/common-w5500-spi-id.yaml +++ /dev/null @@ -1,17 +0,0 @@ -ethernet: - type: W5500 - spi_id: spi_bus - cs_pin: 5 - interrupt_pin: 36 - reset_pin: 22 - clock_speed: 10Mhz - manual_ip: - static_ip: 192.168.178.56 - gateway: 192.168.178.1 - subnet: 255.255.255.0 - domain: .local - mac_address: "02:AA:BB:CC:DD:01" - on_connect: - - logger.log: "Ethernet connected!" - on_disconnect: - - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/test-ip101.esp32-idf.yaml b/tests/components/ethernet/test-ip101.esp32-idf.yaml index e52329d7ea..628b63d4e9 100644 --- a/tests/components/ethernet/test-ip101.esp32-idf.yaml +++ b/tests/components/ethernet/test-ip101.esp32-idf.yaml @@ -1 +1,6 @@ -<<: !include common-ip101.yaml +packages: + ethernet: !include common-ip101.yaml + +# IPv6 on the internal EMAC: compiles the IPv6 event handler and multicast filter +network: + enable_ipv6: true diff --git a/tests/components/ethernet/test-ipv6.esp32-idf.yaml b/tests/components/ethernet/test-ipv6.esp32-idf.yaml deleted file mode 100644 index 607e82dfa9..0000000000 --- a/tests/components/ethernet/test-ipv6.esp32-idf.yaml +++ /dev/null @@ -1,12 +0,0 @@ -network: - enable_ipv6: true - -ethernet: - type: IP101 - mdc_pin: 23 - mdio_pin: 18 - clk: - pin: 0 - mode: CLK_EXT_IN - phy_addr: 1 - power_pin: 5 diff --git a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml deleted file mode 100644 index 904a916789..0000000000 --- a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml +++ /dev/null @@ -1,39 +0,0 @@ -ethernet: - id: eth - type: W5500 - clk_pin: 19 - mosi_pin: 21 - miso_pin: 23 - cs_pin: 18 - interrupt_pin: 36 - reset_pin: 22 - enable_on_boot: false - manual_ip: - static_ip: 192.168.178.56 - gateway: 192.168.178.1 - subnet: 255.255.255.0 - mac_address: "02:AA:BB:CC:DD:01" - interface: spi2 - -esphome: - on_boot: - priority: 200 - then: - - if: - condition: - not: - ethernet.enabled: - then: - - ethernet.enable: - -button: - - platform: template - name: "Disable Ethernet" - on_press: - - ethernet.disable: - -binary_sensor: - - platform: template - name: "Ethernet Connected" - lambda: |- - return id(eth).is_connected(); diff --git a/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml b/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml deleted file mode 100644 index 16a25ced2a..0000000000 --- a/tests/components/ethernet/test-w5500-spi-id.esp32-idf.yaml +++ /dev/null @@ -1,3 +0,0 @@ -packages: - spi: !include ../../test_build_components/common/spi/esp32-idf.yaml - ethernet: !include common-w5500-spi-id.yaml diff --git a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml deleted file mode 100644 index d454dbadc3..0000000000 --- a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM -packages: - ethernet: !include common-w5500.yaml - -psram: - mode: quad - -wifi: - ssid: MySSID - password: password1 - -network: - priority: - - ethernet - - wifi diff --git a/tests/components/ethernet/test-w5500.esp32-idf.yaml b/tests/components/ethernet/test-w5500.esp32-idf.yaml index f1551fef60..c99dfaf528 100644 --- a/tests/components/ethernet/test-w5500.esp32-idf.yaml +++ b/tests/components/ethernet/test-w5500.esp32-idf.yaml @@ -1,20 +1,47 @@ +# W5500 on a shared SPI bus, started from an automation instead of on boot. +# Covers the spi_id attach path and the enable/disable lifecycle in one build. +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + ethernet: + id: eth type: W5500 - clk_pin: 19 - mosi_pin: 21 - miso_pin: 23 - cs_pin: 18 + spi_id: spi_bus + cs_pin: 5 interrupt_pin: 36 reset_pin: 22 clock_speed: 10Mhz + enable_on_boot: false manual_ip: static_ip: 192.168.178.56 gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" - interface: spi2 on_connect: - logger.log: "Ethernet connected!" on_disconnect: - logger.log: "Ethernet disconnected!" + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + ethernet.enabled: + then: + - ethernet.enable: + +button: + - platform: template + name: "Disable Ethernet" + on_press: + - ethernet.disable: + +binary_sensor: + - platform: template + name: "Ethernet Connected" + lambda: |- + return id(eth).is_connected(); diff --git a/tests/components/ethernet/test.esp32-s3-idf.yaml b/tests/components/ethernet/test.esp32-s3-idf.yaml index 36f1b5365f..d454dbadc3 100644 --- a/tests/components/ethernet/test.esp32-s3-idf.yaml +++ b/tests/components/ethernet/test.esp32-s3-idf.yaml @@ -1 +1,15 @@ -<<: !include common-w5500.yaml +# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM +packages: + ethernet: !include common-w5500.yaml + +psram: + mode: quad + +wifi: + ssid: MySSID + password: password1 + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/ethernet/test-ksz8081rna.esp32-idf.yaml b/tests/components/ethernet/validate-ksz8081rna.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-ksz8081rna.esp32-idf.yaml rename to tests/components/ethernet/validate-ksz8081rna.esp32-idf.yaml diff --git a/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml b/tests/components/ethernet/validate-lan8720-with-expander.esp32-idf.yaml similarity index 100% rename from tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml rename to tests/components/ethernet/validate-lan8720-with-expander.esp32-idf.yaml diff --git a/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml b/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml new file mode 100644 index 0000000000..a2c62e4a52 --- /dev/null +++ b/tests/components/ethernet/validate-w5500-interface.esp32-idf.yaml @@ -0,0 +1,12 @@ +# Config only: a user supplied SPI interface on dedicated pins exercises the +# interface option in validation. +ethernet: + type: W5500 + interface: spi3 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 17 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 12 + clock_speed: 10Mhz From 0b40892775d1083b1aeae154c53946a3d056a2cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:31:42 -1000 Subject: [PATCH 084/268] [mcp23xxx_base] Keep the interrupt handler in IRAM on ESP8266 (#20409) --- esphome/components/mcp23xxx_base/mcp23xxx_base.cpp | 2 ++ esphome/components/mcp23xxx_base/mcp23xxx_base.h | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index b8032fcec8..8ad2f7521e 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -4,6 +4,8 @@ namespace esphome::mcp23xxx_base { +void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg) { arg->enable_loop_soon_any_context(); } + template void MCP23XXXGPIOPin::setup() { this->pin_mode(flags_); // When interrupt_pin is configured, pin_mode() already auto-enables CHANGE diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 1c45b0f4af..7414677175 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -8,6 +8,10 @@ namespace esphome::mcp23xxx_base { enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANGE, MCP23XXX_RISING, MCP23XXX_FALLING }; +// Not a template member: GCC 10 (ESP8266) ignores section attributes on template instantiations, leaving the +// ISR in flash. +void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg); + template class MCP23XXXBase : public Component, public gpio_expander::CachedGpioExpander { public: virtual void pin_mode(uint8_t pin, gpio::Flags flags); @@ -35,7 +39,8 @@ template class MCP23XXXBase : public Component, public gpio_expander: void setup_interrupt_pin_() { if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&MCP23XXXBase::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->interrupt_pin_->attach_interrupt(&mcp23xxx_gpio_intr, static_cast(this), + gpio::INTERRUPT_FALLING_EDGE); this->set_invalidate_on_read_(false); } // Disable loop until an input pin is configured via pin_mode() @@ -43,7 +48,6 @@ template class MCP23XXXBase : public Component, public gpio_expander: // For polling mode, loop is re-enabled when pin_mode() registers an input pin this->disable_loop(); } - static void IRAM_ATTR gpio_intr(MCP23XXXBase *arg) { arg->enable_loop_soon_any_context(); } // read a given register virtual bool read_reg(uint8_t reg, uint8_t *value) = 0; From db517059a3bc730ecef59964f8941a6d9aebcd90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 06:32:07 -1000 Subject: [PATCH 085/268] [ota] Collapse duplicate CI test variants (#20407) --- tests/components/ota/common.yaml | 5 ----- tests/components/ota/test-api_key_offer.esp8266-ard.yaml | 2 -- .../components/ota/test-api_runtime_key.esp8266-ard.yaml | 2 -- .../components/ota/test-partition_access.esp32-idf.yaml | 5 ----- tests/components/ota/test.bk72xx-ard.yaml | 4 +++- tests/components/ota/test.esp32-ard.yaml | 4 +++- tests/components/ota/test.esp32-idf.yaml | 9 ++++++++- tests/components/ota/test.esp8266-ard.yaml | 4 +++- tests/components/ota/test.host.yaml | 6 ++---- tests/components/ota/test.rp2040-ard.yaml | 4 +++- ...ard.yaml => validate-empty_password.esp8266-ard.yaml} | 0 ...yaml => validate-encryption_inherit.esp8266-ard.yaml} | 0 tests/components/ota/wifi.yaml | 3 +++ 13 files changed, 25 insertions(+), 23 deletions(-) delete mode 100644 tests/components/ota/test-api_key_offer.esp8266-ard.yaml delete mode 100644 tests/components/ota/test-api_runtime_key.esp8266-ard.yaml delete mode 100644 tests/components/ota/test-partition_access.esp32-idf.yaml rename tests/components/ota/{test-empty_password.esp8266-ard.yaml => validate-empty_password.esp8266-ard.yaml} (100%) rename tests/components/ota/{test-encryption_inherit.esp8266-ard.yaml => validate-encryption_inherit.esp8266-ard.yaml} (100%) create mode 100644 tests/components/ota/wifi.yaml diff --git a/tests/components/ota/common.yaml b/tests/components/ota/common.yaml index 1433dada1f..af8cf66708 100644 --- a/tests/components/ota/common.yaml +++ b/tests/components/ota/common.yaml @@ -1,11 +1,6 @@ -wifi: - ssid: MySSID - password: password1 - ota: - platform: esphome password: "superlongpasswordthatnoonewillknow" - port: 3286 on_begin: then: - logger.log: "OTA start" diff --git a/tests/components/ota/test-api_key_offer.esp8266-ard.yaml b/tests/components/ota/test-api_key_offer.esp8266-ard.yaml deleted file mode 100644 index ecda625521..0000000000 --- a/tests/components/ota/test-api_key_offer.esp8266-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - ota: !include api_key_offer.yaml diff --git a/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml b/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml deleted file mode 100644 index 4709a9e45c..0000000000 --- a/tests/components/ota/test-api_runtime_key.esp8266-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - ota: !include api_runtime_key.yaml diff --git a/tests/components/ota/test-partition_access.esp32-idf.yaml b/tests/components/ota/test-partition_access.esp32-idf.yaml deleted file mode 100644 index 0cbf854952..0000000000 --- a/tests/components/ota/test-partition_access.esp32-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -ota: - - platform: esphome - allow_partition_access: true - -<<: !include common.yaml diff --git a/tests/components/ota/test.bk72xx-ard.yaml b/tests/components/ota/test.bk72xx-ard.yaml index dade44d145..3b32457cf4 100644 --- a/tests/components/ota/test.bk72xx-ard.yaml +++ b/tests/components/ota/test.bk72xx-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.esp32-ard.yaml b/tests/components/ota/test.esp32-ard.yaml index dade44d145..3b32457cf4 100644 --- a/tests/components/ota/test.esp32-ard.yaml +++ b/tests/components/ota/test.esp32-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.esp32-idf.yaml b/tests/components/ota/test.esp32-idf.yaml index dade44d145..7f25fafadb 100644 --- a/tests/components/ota/test.esp32-idf.yaml +++ b/tests/components/ota/test.esp32-idf.yaml @@ -1 +1,8 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml + +# Final validation merges this into the common block +ota: + - platform: esphome + allow_partition_access: true diff --git a/tests/components/ota/test.esp8266-ard.yaml b/tests/components/ota/test.esp8266-ard.yaml index dade44d145..3b32457cf4 100644 --- a/tests/components/ota/test.esp8266-ard.yaml +++ b/tests/components/ota/test.esp8266-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test.host.yaml b/tests/components/ota/test.host.yaml index ae7c4d0add..fdfbf47139 100644 --- a/tests/components/ota/test.host.yaml +++ b/tests/components/ota/test.host.yaml @@ -1,4 +1,2 @@ -<<: !include common.yaml - -#host platform does not support wifi / network is automatically included -wifi: !remove +packages: + ota: !include common.yaml diff --git a/tests/components/ota/test.rp2040-ard.yaml b/tests/components/ota/test.rp2040-ard.yaml index dade44d145..3b32457cf4 100644 --- a/tests/components/ota/test.rp2040-ard.yaml +++ b/tests/components/ota/test.rp2040-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + wifi: !include wifi.yaml + ota: !include common.yaml diff --git a/tests/components/ota/test-empty_password.esp8266-ard.yaml b/tests/components/ota/validate-empty_password.esp8266-ard.yaml similarity index 100% rename from tests/components/ota/test-empty_password.esp8266-ard.yaml rename to tests/components/ota/validate-empty_password.esp8266-ard.yaml diff --git a/tests/components/ota/test-encryption_inherit.esp8266-ard.yaml b/tests/components/ota/validate-encryption_inherit.esp8266-ard.yaml similarity index 100% rename from tests/components/ota/test-encryption_inherit.esp8266-ard.yaml rename to tests/components/ota/validate-encryption_inherit.esp8266-ard.yaml diff --git a/tests/components/ota/wifi.yaml b/tests/components/ota/wifi.yaml new file mode 100644 index 0000000000..ac662302fc --- /dev/null +++ b/tests/components/ota/wifi.yaml @@ -0,0 +1,3 @@ +wifi: + ssid: MySSID + password: password1 From b1f5da956149c1ba1fec5d80123a8b454b831294 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:11:30 -1000 Subject: [PATCH 086/268] [esp8266] Keep ArduinoJson's float tables in flash (Arduino core 3.1.2-esphome.3) (#20416) --- esphome/arduino8266/framework.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 48fd72d1da..82e00ff897 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple): # Arduino core version -> its build in esphome-libs/arduino-esp8266 FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = { Version(3, 1, 2): FrameworkRelease( - "3.1.2-esphome.2", - "9377d63f569c524823199b1897ed7fde2063e50e2676dd519a0d758ab73678f7", - 37192128, + "3.1.2-esphome.3", + "3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db", + 37185650, ), } From 061733672faca783e8ad4bcf8304cec2077f3ce9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:12:35 -1000 Subject: [PATCH 087/268] [ci] Refit component batch estimates to a full run (#20401) --- script/split_components_for_ci.py | 172 ++++++++++++++----- tests/script/test_determine_jobs.py | 14 +- tests/script/test_split_components_for_ci.py | 121 +++++++++++-- 3 files changed, 248 insertions(+), 59 deletions(-) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index cc458d5d66..26907936c1 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -13,15 +13,20 @@ from __future__ import annotations import argparse from collections import defaultdict +from collections.abc import Set as AbstractSet +import contextlib from dataclasses import dataclass, field +import functools import json import math from pathlib import Path import sys +from typing import Any, NamedTuple # Add esphome to path sys.path.insert(0, str(Path(__file__).parent.parent)) +from esphome import yaml_util from script.analyze_component_buses import ( ISOLATED_COMPONENTS, ISOLATED_SIGNATURE_PREFIX, @@ -31,29 +36,52 @@ from script.analyze_component_buses import ( merge_compatible_bus_groups, ) from script.helpers import ( + _extract_components_from_yaml, get_component_test_files, parse_test_filename, split_conflicting_groups, ) -# Estimated CI seconds for one build per test platform, read from batch job -# logs. Absolute accuracy does not matter, only the ratios between platforms. -# A full run takes over an hour, so recording real timings is not practical. +# Estimated CI seconds for one build per test platform, fitted to the batch +# job logs of a full run (every component, PR #20389). Absolute accuracy does +# not matter, only the ratios between platforms. A full run takes over an +# hour, so recording real timings is not practical. PLATFORM_BUILD_SECONDS = { "host": 10, "esp8266-ard": 30, - "esp32-idf": 45, - "rtl87xx-ard": 35, - "ln882x-ard": 50, - "rp2040-ard": 50, - "rp2350-ard": 50, - "bk72xx-ard": 60, + "rtl87xx-ard": 40, + "esp32-c3-idf": 40, + "esp32-s2-idf": 40, + "rp2350-ard": 40, + "rp2040-ard": 45, + "esp32-idf": 55, + "esp32-p4-idf": 55, + "ln882x-ard": 55, + "esp32-s3-idf": 60, + "bk72xx-ard": 70, + "esp32-c6-idf": 70, } -NRF52_BUILD_SECONDS = 45 -# Other ESP32 chips and Arduino on ESP32 share less of the ccache -DEFAULT_BUILD_SECONDS = 90 +NRF52_BUILD_SECONDS = 50 +# Arduino on any ESP32 chip builds the IDF plus the Arduino core on top +ESP32_ARDUINO_BUILD_SECONDS = 90 +# The remaining ESP32 chips on ESP-IDF +DEFAULT_BUILD_SECONDS = 50 # Each extra component merged into a grouped build makes it larger -GROUPED_COMPONENT_SECONDS = 5 +GROUPED_COMPONENT_SECONDS = 3 +# Components that pull in a large library the build compiles from source +# (TensorFlow Lite Micro, FastLED, LVGL, the HTTP server), added to every +# build whose test config enables them. Fitted the same way as the platforms. +HEAVY_COMPONENT_SECONDS: dict[str, int] = { + "micro_wake_word": 200, + "fastled_clockless": 90, + "fastled_spi": 90, + "wled": 60, + "lvgl": 50, + "web_server": 50, + "online_image": 45, + "prometheus": 35, + "bluetooth_proxy": 30, +} # Estimated build seconds per CI runner; sets how many runners are used. # Approximate: the runner count charges each grouped build once, but a large # group spreads over several runners, which each pay for that build. @@ -90,9 +118,62 @@ def build_seconds(platform: str) -> int: return seconds if platform.startswith("nrf52"): return NRF52_BUILD_SECONDS + if platform.startswith("esp32") and platform.endswith("-ard"): + return ESP32_ARDUINO_BUILD_SECONDS return DEFAULT_BUILD_SECONDS +def _config_components(data: Any) -> set[str]: + """Return the components a loaded test config and its ``packages:`` use.""" + if not isinstance(data, dict): + return set() + found = _extract_components_from_yaml(data) + if isinstance(packages := data.get("packages"), dict): + for package in packages.values(): + if isinstance(package, yaml_util.IncludeFile): + found |= _file_components( + (package.parent_file.parent / package.file).resolve() + ) + else: + found |= _config_components(package) + return found + + +@functools.cache +def _file_components(path: Path) -> frozenset[str]: + """Return the components a config file uses; shared packages load once.""" + return frozenset( + _config_components(yaml_util.load_yaml(path, track_document_range=False)) + ) + + +def heavy_components(component: str, test_file: Path) -> frozenset[str]: + """Return the heavy components a test compiles. + + A test counts as heavy when it belongs to a heavy component or when its + config enables one, such as a display test that draws with lvgl. + """ + heavy = {component} & HEAVY_COMPONENT_SECONDS.keys() + # A config that does not load is left to the build to report + with contextlib.suppress(Exception): + heavy |= _file_components(test_file.resolve()) & HEAVY_COMPONENT_SECONDS.keys() + return frozenset(heavy) + + +def heavy_seconds(heavy: AbstractSet[str]) -> int: + """Return the library seconds a set of heavy components adds to a build.""" + return sum(HEAVY_COMPONENT_SECONDS[name] for name in heavy) + + +class _GroupedShare(NamedTuple): + """A component's part in one (signature, platform) shared build.""" + + # Seconds of its files on that platform when no other member shares it + solo: int + # Heavy components its base test brings into the shared build + heavy: frozenset[str] + + @dataclass class _BatchItem: """A component and the builds it adds to the batch it lands in.""" @@ -101,40 +182,52 @@ class _BatchItem: # Builds that always run on their own (isolated tests, variants on a # platform without a base test) own_seconds: int - # Per (signature, platform) shared build: the seconds of this component's - # files on that platform when no other batch member shares the build - grouped_builds: dict[tuple[str, str], int] = field(default_factory=dict) + grouped_builds: dict[tuple[str, str], _GroupedShare] = field(default_factory=dict) def standalone_seconds(self) -> int: - return self.own_seconds + sum(self.grouped_builds.values()) + return self.own_seconds + sum( + share.solo for share in self.grouped_builds.values() + ) -def _grouped_build_seconds(platform: str, solo_seconds: list[int]) -> int: +def _grouped_build_seconds(platform: str, members: list[_GroupedShare]) -> int: """Return the seconds of one (signature, platform) build in a batch. + A library two members both enable compiles once, so the union is charged. test_build_components only groups when two or more members share the build; a grouped member then skips its variants on that platform. """ - if len(solo_seconds) <= 1: - return sum(solo_seconds) - return build_seconds(platform) + GROUPED_COMPONENT_SECONDS * (len(solo_seconds) - 1) + if len(members) <= 1: + return sum(share.solo for share in members) + return ( + build_seconds(platform) + + GROUPED_COMPONENT_SECONDS * (len(members) - 1) + + heavy_seconds(set().union(*(share.heavy for share in members))) + ) def _make_item( tests_dir: Path, component: str, signature: str, is_isolated: bool ) -> _BatchItem: - files_by_platform: dict[str, list[str]] = defaultdict(list) + # Per platform: the seconds of its test files, and the base test's heavy + # components; a platform without a base test cannot join a shared build + seconds_by_platform: dict[str, int] = defaultdict(int) + base_heavy: dict[str, frozenset[str]] = {} for test_file in (tests_dir / component).glob("test[.-]*.yaml"): test_name, platform = parse_test_filename(test_file) - files_by_platform[platform].append(test_name) + heavy = heavy_components(component, test_file) + seconds_by_platform[platform] += build_seconds(platform) + heavy_seconds(heavy) + if test_name == "test": + base_heavy[platform] = heavy own_seconds = 0 - grouped: dict[tuple[str, str], int] = {} - for platform, test_names in files_by_platform.items(): - seconds = build_seconds(platform) * len(test_names) - if is_isolated or "test" not in test_names: + grouped: dict[tuple[str, str], _GroupedShare] = {} + for platform, seconds in seconds_by_platform.items(): + if is_isolated or platform not in base_heavy: own_seconds += seconds else: - grouped[(signature, platform)] = seconds + grouped[(signature, platform)] = _GroupedShare( + seconds, base_heavy[platform] + ) return _BatchItem(component, own_seconds, grouped) @@ -144,22 +237,24 @@ class _Batch: def __init__(self) -> None: self.components: list[str] = [] self.seconds = 0 - self.grouped_builds: dict[tuple[str, str], list[int]] = defaultdict(list) + self.grouped_builds: dict[tuple[str, str], list[_GroupedShare]] = defaultdict( + list + ) def added_seconds(self, item: _BatchItem) -> int: """Return the seconds item would add; joining an existing build is cheap.""" added = item.own_seconds - for (signature, platform), solo in item.grouped_builds.items(): + for (signature, platform), share in item.grouped_builds.items(): members = self.grouped_builds.get((signature, platform), []) added += _grouped_build_seconds( - platform, [*members, solo] + platform, [*members, share] ) - _grouped_build_seconds(platform, members) return added def add(self, item: _BatchItem) -> None: self.seconds += self.added_seconds(item) - for build, solo in item.grouped_builds.items(): - self.grouped_builds[build].append(solo) + for build, share in item.grouped_builds.items(): + self.grouped_builds[build].append(share) self.components.append(item.component) @@ -172,14 +267,11 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s """ if not items: return [] - grouped_builds: dict[tuple[str, str], list[int]] = defaultdict(list) + # One batch holding every item counts each grouped build once + whole = _Batch() for item in items: - for build, solo in item.grouped_builds.items(): - grouped_builds[build].append(solo) - total = sum(item.own_seconds for item in items) + sum( - _grouped_build_seconds(platform, solos) - for (_, platform), solos in grouped_builds.items() - ) + whole.add(item) + total = whole.seconds count = min(len(items), max(1, math.ceil(total / target_seconds))) batches = [_Batch() for _ in range(count)] for item in sorted(items, key=lambda i: (-i.standalone_seconds(), i.component)): diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 7e9b09b2b9..e63e0849de 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -2613,8 +2613,8 @@ def test_component_batching_beta_branch_groups_evenly( For beta/release branches, all components should be groupable (not isolated), so they share one grouped build per runner and spread evenly. """ - # Create 120 test components with test files - component_names = [f"comp_{i:03d}" for i in range(120)] + # Create 200 test components with test files + component_names = [f"comp_{i:03d}" for i in range(200)] tests_dir = tmp_path / "tests" / "components" for comp in component_names: @@ -2675,15 +2675,15 @@ def test_component_batching_beta_branch_groups_evenly( assert "component_test_batches" in output batches = output["component_test_batches"] - # One grouped esp32-idf build plus 5 s per extra component is 640 s, - # so two runners of 60 components each - assert [len(batch["components"].split()) for batch in batches] == [60, 60] + # One grouped esp32-idf build plus 3 s per extra component is 652 s, + # so two runners of 100 components each + assert [len(batch["components"].split()) for batch in batches] == [100, 100] - # Verify all 120 components are in batches + # Verify all 200 components are in batches all_components = [] for batch in batches: all_components.extend(batch["components"].split()) - assert len(all_components) == 120 + assert len(all_components) == 200 assert set(all_components) == set(component_names) diff --git a/tests/script/test_split_components_for_ci.py b/tests/script/test_split_components_for_ci.py index 88a1a17b33..4da644d679 100644 --- a/tests/script/test_split_components_for_ci.py +++ b/tests/script/test_split_components_for_ci.py @@ -35,13 +35,46 @@ def test_build_seconds() -> None: """Known platforms use the table; nrf52 boards share one estimate.""" build_seconds = split_components_for_ci.build_seconds assert build_seconds("host") == 10 - assert build_seconds("esp32-idf") == 45 + assert build_seconds("esp32-idf") == 55 assert ( build_seconds("nrf52-xiao-ble") == split_components_for_ci.NRF52_BUILD_SECONDS ) assert ( - build_seconds("esp32-s3-idf") == split_components_for_ci.DEFAULT_BUILD_SECONDS + build_seconds("esp32-c5-idf") == split_components_for_ci.DEFAULT_BUILD_SECONDS ) + assert ( + build_seconds("esp32-c3-ard") + == split_components_for_ci.ESP32_ARDUINO_BUILD_SECONDS + ) + + +def test_heavy_components(tests_dir: Path) -> None: + """A heavy component, or a test that enables one, costs its library time.""" + heavy_components = split_components_for_ci.heavy_components + heavy_seconds = split_components_for_ci.heavy_seconds + table = split_components_for_ci.HEAVY_COMPONENT_SECONDS + _add_component(tests_dir, "dht", ["test.esp32-idf.yaml"]) + _add_component(tests_dir, "micro_wake_word", ["test.esp32-idf.yaml"]) + assert heavy_components("dht", tests_dir / "dht/test.esp32-idf.yaml") == set() + assert heavy_components( + "micro_wake_word", tests_dir / "micro_wake_word/test.esp32-idf.yaml" + ) == {"micro_wake_word"} + assert heavy_seconds(frozenset({"micro_wake_word"})) == table["micro_wake_word"] + + display = tests_dir / "mipi_spi" + display.mkdir() + (display / "common.yaml").write_text("web_server:\n port: 80\n") + (display / "test-lvgl.esp32-s3-idf.yaml").write_text( + "packages:\n" + " mipi_spi: !include common.yaml\n" + "lvgl:\n pages: []\n" + "light:\n - platform: fastled_spi\n num_leds: 1\n" + ) + assert heavy_components("mipi_spi", display / "test-lvgl.esp32-s3-idf.yaml") == { + "lvgl", + "web_server", + "fastled_spi", + } def test_make_item_isolated_and_grouped(tests_dir: Path) -> None: @@ -61,29 +94,93 @@ def test_make_item_isolated_and_grouped(tests_dir: Path) -> None: isolated = split_components_for_ci._make_item( tests_dir, "comp", "isolated_comp", True ) - assert isolated.own_seconds == 45 + 10 + 45 + 50 + assert isolated.own_seconds == 55 + 10 + 55 + 45 assert isolated.grouped_builds == {} # The rp2040 variant has no base test to group with, so it always runs grouped = split_components_for_ci._make_item(tests_dir, "comp", "i2c", False) - assert grouped.own_seconds == 50 - assert grouped.grouped_builds == {("i2c", "esp32-idf"): 90, ("i2c", "host"): 10} + assert grouped.own_seconds == 45 + assert grouped.grouped_builds == { + ("i2c", "esp32-idf"): (110, frozenset()), + ("i2c", "host"): (10, frozenset()), + } + + +def test_make_item_heavy_component(tests_dir: Path) -> None: + """A heavy component's library cost follows it into every build.""" + _add_component(tests_dir, "lvgl", ["test.esp32-idf.yaml", "test-a.host.yaml"]) + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["lvgl"] + + item = split_components_for_ci._make_item(tests_dir, "lvgl", "spi", False) + assert item.own_seconds == 10 + extra + assert item.grouped_builds == { + ("spi", "esp32-idf"): (55 + extra, frozenset({"lvgl"})) + } + + +def test_heavy_cost_is_per_platform(tests_dir: Path) -> None: + """A base test that enables a heavy component only charges its own platform.""" + comp = tests_dir / "web_server_idf" + comp.mkdir() + (comp / "test.esp32-idf.yaml").write_text("web_server:\n port: 80\n") + (comp / "test.host.yaml").write_text("logger:\n") + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["web_server"] + + item = split_components_for_ci._make_item( + tests_dir, "web_server_idf", "none", False + ) + assert item.grouped_builds == { + ("none", "esp32-idf"): (55 + extra, frozenset({"web_server"})), + ("none", "host"): (10, frozenset()), + } def test_grouped_component_joins_existing_build() -> None: """A second member turns a lone build into a group that skips variants.""" item = split_components_for_ci._BatchItem + share = split_components_for_ci._GroupedShare batch = split_components_for_ci._Batch() - batch.add(item("a", 0, {("i2c", "esp32-idf"): 90})) - assert batch.seconds == 90 + batch.add(item("a", 0, {("i2c", "esp32-idf"): share(110, frozenset())})) + assert batch.seconds == 110 - other = item("b", 0, {("i2c", "esp32-idf"): 45, ("i2c", "host"): 10}) - grouped = 45 + split_components_for_ci.GROUPED_COMPONENT_SECONDS - assert batch.added_seconds(other) == grouped - 90 + 10 + other = item( + "b", + 0, + { + ("i2c", "esp32-idf"): share(55, frozenset()), + ("i2c", "host"): share(10, frozenset()), + }, + ) + grouped = 55 + split_components_for_ci.GROUPED_COMPONENT_SECONDS + assert batch.added_seconds(other) == grouped - 110 + 10 batch.add(other) assert batch.seconds == grouped + 10 +def test_heavy_component_charges_the_shared_build() -> None: + """A heavy member makes the whole grouped build slower, once per library.""" + item = split_components_for_ci._BatchItem + share = split_components_for_ci._GroupedShare + batch = split_components_for_ci._Batch() + extra = split_components_for_ci.HEAVY_COMPONENT_SECONDS["lvgl"] + per_member = split_components_for_ci.GROUPED_COMPONENT_SECONDS + lvgl = frozenset({"lvgl"}) + batch.add(item("a", 0, {("spi", "esp32-idf"): share(55, frozenset())})) + + heavy = item("lvgl", 0, {("spi", "esp32-idf"): share(55 + extra, lvgl)}) + grouped = 55 + per_member + extra + assert batch.added_seconds(heavy) == grouped - 55 + batch.add(heavy) + assert batch.seconds == grouped + + light = item("b", 0, {("spi", "esp32-idf"): share(55, frozenset())}) + assert batch.added_seconds(light) == per_member + + # A second member that also draws with lvgl compiles the library once + also_heavy = item("c", 0, {("spi", "esp32-idf"): share(55 + extra, lvgl)}) + assert batch.added_seconds(also_heavy) == per_member + + def test_isolated_components_balance_across_runners(tests_dir: Path) -> None: """Heavy isolated components spread out and light ones fill the gaps.""" platforms = ["esp32-idf", "esp8266-ard", "rp2040-ard", "bk72xx-ard"] @@ -138,7 +235,7 @@ def test_groupable_components_split_evenly(tests_dir: Path) -> None: components=names, tests_dir=tests_dir, target_seconds=100 ) - assert sorted(len(batch) for batch in batches) == [2, 2, 2, 3, 3] + assert sorted(len(batch) for batch in batches) == [3, 3, 3, 3] assert sorted(c for batch in batches for c in batch) == names @@ -149,7 +246,7 @@ def test_make_item_reads_tests_dir(tmp_path: Path) -> None: _add_component(other, "comp", ["test.esp32-idf.yaml"]) item = split_components_for_ci._make_item(other, "comp", "isolated_comp", True) - assert item.own_seconds == 45 + assert item.own_seconds == 55 def test_balance_batches_empty() -> None: From 22e594bc629c17833fba59f4c53cc571cc32ec85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:16:17 -1000 Subject: [PATCH 088/268] [esp32_ble_client] Track conn_id per attempt and stop logging an error on a failed open (#20417) --- .../esp32_ble_client/ble_client_base.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 88454f7bdb..cfd77e84f4 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -150,6 +150,8 @@ void BLEClientBase::connect() { // A registration whose event never arrived must not block this connection's release. this->services_released_ = false; this->pending_notify_regs_ = 0; + // A CONNECT_EVT seen while idle can leave the id of a link this attempt does not own. + this->conn_id_ = UNSET_CONN_ID; // Enable loop for state processing this->enable_loop(); // Immediately transition to CONNECTING to prevent duplicate connection attempts @@ -350,7 +352,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->open.remote_bda)) return false; this->log_gattc_lifecycle_event_("OPEN"); - // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an @@ -362,19 +363,24 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } - if (this->state() != espbt::ClientState::CONNECTING) { - // This should not happen but lets log it in case it does - // because it means we have a bad assumption about how the - // ESP BT stack works. + const bool open_failed = param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN; + // Bluedroid reports a failed open as DISCONNECT_EVT then a failing OPEN_EVT, with no CONNECT_EVT, + // so DISCONNECTING with no id is expected for a failure. Anything else means a bad assumption + // about how the ESP BT stack works, so log it. + const bool expected_failure = + open_failed && this->state() == espbt::ClientState::DISCONNECTING && this->conn_id_ == UNSET_CONN_ID; + if (this->state() != espbt::ClientState::CONNECTING && !expected_failure) { ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, this->address_str_, espbt::client_state_to_string(this->state()), param->open.status); } - if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + if (open_failed) { this->log_gattc_warning_("Connection open", param->open.status); // Connection was never established so CLOSE_EVT may not follow this->set_idle_(); break; } + // An open on a link that is already up sends no CONNECT_EVT, so take the id here. + this->conn_id_ = param->open.conn_id; if (this->want_disconnect_) { // Disconnect was requested after connecting started, // but before the connection was established. Now that we have From e61d348f0c245bad74acca633e420423618f2914 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:20:59 -1000 Subject: [PATCH 089/268] [wifi] Use numeric scheduler ids instead of string names (#20357) --- esphome/components/wifi/automation.h | 11 +++++++---- esphome/components/wifi/wifi_component.cpp | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index c14341330f..eb3d7471dc 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -76,13 +76,13 @@ template class WiFiConfigureAction final : public Action, // Enable WiFi global_wifi_component->enable(); // Set timeout for the connection - this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() { + this->set_timeout(CONNECT_TIMEOUT_ID, this->connection_timeout_.value(x...), [this, x...]() { // If the timeout is reached, stop connecting and revert to the old AP global_wifi_component->disable(); global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password()); global_wifi_component->enable(); // Start a timeout for the fallback if the connection to the old AP fails - this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { + this->set_timeout(FALLBACK_TIMEOUT_ID, this->connection_timeout_.value(x...), [this]() { this->connecting_ = false; this->error_trigger_.trigger(); }); @@ -97,8 +97,8 @@ template class WiFiConfigureAction final : public Action, return; if (global_wifi_component->is_connected()) { // The WiFi is connected, stop the timeout and reset the connecting flag - this->cancel_timeout("wifi-connect-timeout"); - this->cancel_timeout("wifi-fallback-timeout"); + this->cancel_timeout(CONNECT_TIMEOUT_ID); + this->cancel_timeout(FALLBACK_TIMEOUT_ID); this->connecting_ = false; char ssid_buf[SSID_BUFFER_SIZE]; if (strcmp(global_wifi_component->wifi_ssid_to(ssid_buf), this->new_sta_.get_ssid().c_str()) == 0) { @@ -112,6 +112,9 @@ template class WiFiConfigureAction final : public Action, } protected: + static constexpr uint32_t CONNECT_TIMEOUT_ID = 0; + static constexpr uint32_t FALLBACK_TIMEOUT_ID = 1; + bool connecting_{false}; WiFiAP new_sta_; WiFiAP old_sta_; diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index fd982c0d31..594e59827a 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -398,6 +398,7 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000; /// disconnect event cannot keep the networks from being tried for good. static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS; static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000; +static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; #endif /// Cooldown duration when fallback AP is active and captive portal may be running @@ -1222,7 +1223,7 @@ void WiFiComponent::connect_soon_() { // New credentials from the portal: the AP pauses for them once its answer // is out, and the STA state machine picks them up from there. if (this->ap_setup_) { - this->set_timeout("wifi-ap-handover", WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { + this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { if (this->ap_setup_) this->pause_exclusive_ap_(); }); From 3b0c4857016f9a447b175adc51fa98c37045db41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:21:29 -1000 Subject: [PATCH 090/268] [mqtt] Use a numeric scheduler id for the deferred state publish (#20359) --- esphome/components/mqtt/mqtt_component.h | 2 ++ esphome/components/mqtt/mqtt_fan.cpp | 2 +- esphome/components/mqtt/mqtt_light.cpp | 2 +- esphome/components/mqtt/mqtt_lock.cpp | 2 +- esphome/components/mqtt/mqtt_switch.cpp | 2 +- esphome/components/mqtt/mqtt_update.cpp | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index b4ae624404..141b5f12b3 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -273,6 +273,8 @@ class MQTTComponent : public Component { void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); protected: + static constexpr uint32_t SEND_DEFER_ID = 0; + /// Helper method to get the discovery topic for this component into a buffer. StringRef get_discovery_topic_to_(std::span buf, const MQTTDiscoveryInfo &discovery_info) const; diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index 3a8658a55f..6560f200a1 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -121,7 +121,7 @@ void MQTTFanComponent::setup() { }); } - this->state_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->state_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } void MQTTFanComponent::dump_config() { diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index a8b52a3839..31327aa043 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -28,7 +28,7 @@ void MQTTJSONLightComponent::setup() { } void MQTTJSONLightComponent::on_light_remote_values_update() { - this->defer("send", [this]() { this->publish_state_(); }); + this->defer(SEND_DEFER_ID, [this]() { this->publish_state_(); }); } MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {} diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 369c1c6668..7dd7317949 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -29,7 +29,7 @@ void MQTTLockComponent::setup() { } }); this->lock_->add_on_state_callback( - [this](LockState /*state*/) { this->defer("send", [this]() { this->publish_state(); }); }); + [this](LockState /*state*/) { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } void MQTTLockComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Lock '%s': ", this->lock_->get_name().c_str()); diff --git a/esphome/components/mqtt/mqtt_switch.cpp b/esphome/components/mqtt/mqtt_switch.cpp index 70cd03a4eb..c7610b3006 100644 --- a/esphome/components/mqtt/mqtt_switch.cpp +++ b/esphome/components/mqtt/mqtt_switch.cpp @@ -34,7 +34,7 @@ void MQTTSwitchComponent::setup() { } }); this->switch_->add_on_state_callback( - [this](bool enabled) { this->defer("send", [this, enabled]() { this->publish_state(enabled); }); }); + [this](bool enabled) { this->defer(SEND_DEFER_ID, [this, enabled]() { this->publish_state(enabled); }); }); } void MQTTSwitchComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Switch '%s': ", this->switch_->get_name().c_str()); diff --git a/esphome/components/mqtt/mqtt_update.cpp b/esphome/components/mqtt/mqtt_update.cpp index c01fb9e52e..c594a6fe26 100644 --- a/esphome/components/mqtt/mqtt_update.cpp +++ b/esphome/components/mqtt/mqtt_update.cpp @@ -24,7 +24,7 @@ void MQTTUpdateComponent::setup() { } }); - this->update_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->update_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } bool MQTTUpdateComponent::publish_state() { From 01686a5f677e6573ae6e107aa5fb7de00b40b387 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 07:21:53 -1000 Subject: [PATCH 091/268] [improv_serial] Use a numeric scheduler id for the Wi-Fi connect timeout (#20360) --- .../components/improv_serial/improv_serial_component.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 3827fb6ed4..cccb78d3bf 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -18,6 +18,8 @@ namespace esphome::improv_serial { static const char *const TAG = "improv_serial"; +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0; + void ImprovSerialComponent::setup() { global_improv_serial_component = this; #ifdef USE_IMPROV_SERIAL_UART @@ -73,7 +75,7 @@ void ImprovSerialComponent::loop() { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); this->connecting_sta_ = {}; - this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID); this->set_state_(improv::STATE_PROVISIONED); this->send_settings_response_(improv::WIFI_SETTINGS); @@ -302,7 +304,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); - this->set_timeout("wifi-connect-timeout", switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, + this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, [this]() { this->on_wifi_connect_timeout_(); }); #else // No Wi-Fi support compiled in; there is nothing to provision. From 3284d93a33fbe1a3b86c07fd73fa547876056287 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 08:36:42 -1000 Subject: [PATCH 092/268] [nrf52] Fake a larger flash in testing mode (#20439) --- esphome/components/nrf52/__init__.py | 38 ++++++++++++++++++- .../components/zephyr_mcumgr/ota/__init__.py | 8 ++-- .../nrf52/test_testing_mode_flash.py | 30 +++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/components/nrf52/test_testing_mode_flash.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 86d485777c..631959530e 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -12,6 +12,7 @@ from esphome.build_helpers import pch from esphome.build_helpers.ccache import resolve_ccache_path import esphome.codegen as cg from esphome.components.zephyr import ( + Section, add_extra_script, copy_files as zephyr_copy_files, zephyr_add_overlay, @@ -125,11 +126,37 @@ def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR if config[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: - zephyr_add_pm_static(BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]]) + sections = BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]] + if CORE.testing_mode: + sections = _testing_mode_sections(sections) + zephyr_add_pm_static(sections) return config +# In testing mode, fake a larger flash to allow linking grouped component +# tests. The nRF52840 has 1 MB and a mcumgr OTA config halves the app slot, +# which an openthread config alone fills; CI images are never flashed. +NRF52840_FLASH_SIZE = 0x100000 +TESTING_FLASH_SIZE = 0x400000 + + +def _testing_mode_sections(sections: list[Section]) -> list[Section]: + """Move a bootloader pinned to the end of the real flash to the end of the + faked one, so the partition manager still sees a single gap for the app.""" + return [ + Section( + section.name, + section.address + TESTING_FLASH_SIZE - NRF52840_FLASH_SIZE, + section.size, + section.region, + ) + if section.end_address == NRF52840_FLASH_SIZE + else section + for section in sections + ] + + _TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF) _resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF) @@ -380,6 +407,15 @@ async def to_code(config: ConfigType) -> None: zephyr_setup_preferences() zephyr_to_code(config) + if CORE.testing_mode: + zephyr_add_overlay( + f""" + &flash0 {{ + reg = <0x0 {TESTING_FLASH_SIZE:#x}>; + }}; + """ + ) + if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index f5ab04809e..d4f84e30f2 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -13,6 +13,7 @@ from esphome.components.zephyr import ( from esphome.components.zephyr.const import ( BOOTLOADER_MCUBOOT, KEY_BOOTLOADER, + KEY_PM_STATIC, KEY_SYSBUILD, ) from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu @@ -207,10 +208,11 @@ async def to_code(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader != BOOTLOADER_MCUBOOT: - sections = BOOTLOADER_CONFIG[bootloader] - # Derive partition addresses from the SoftDevice and bootloader sections so - # that the DTS flash map matches what the Partition Manager produces: + # Derive partition addresses from the SoftDevice and bootloader sections + # registered with the Partition Manager (moved in testing mode) so that + # the DTS flash map matches what it produces: # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + sections = zephyr_data()[KEY_PM_STATIC] mcuboot_size = 0x9000 sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) bl_start = next(s.address for s in sections if "Adafruit" in s.name) diff --git a/tests/unit_tests/components/nrf52/test_testing_mode_flash.py b/tests/unit_tests/components/nrf52/test_testing_mode_flash.py new file mode 100644 index 0000000000..7c66cd3361 --- /dev/null +++ b/tests/unit_tests/components/nrf52/test_testing_mode_flash.py @@ -0,0 +1,30 @@ +"""Testing mode fakes a larger flash so grouped component tests link.""" + +from esphome.components import nrf52 +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.zephyr import Section + + +def test_testing_mode_sections_move_only_the_bootloader() -> None: + """The bootloader pinned to the end of flash follows the faked end; the + SoftDevice at the start stays.""" + sections = nrf52._testing_mode_sections( + BOOTLOADER_CONFIG[BOOTLOADER_ADAFRUIT_NRF52_SD140_V7] + ) + + assert [(s.name, s.address, s.size) for s in sections] == [ + ("SoftDevice", 0x0, 0x27000), + ( + "Adafruit_nRF52_Bootloader", + nrf52.TESTING_FLASH_SIZE - 0xC000, + 0xC000, + ), + ] + assert sections[1].end_address == nrf52.TESTING_FLASH_SIZE + + +def test_testing_mode_sections_leave_unpinned_sections() -> None: + section = Section("other", 0x50000, 0x1000, "flash_primary") + + assert nrf52._testing_mode_sections([section]) == [section] From 54f86537aded462e25a936080c902d70e593963e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:02:22 -1000 Subject: [PATCH 093/268] [esp32][libretiny] Skip registering static destructors that never run (#20440) --- esphome/components/esp32/__init__.py | 5 +++++ esphome/components/esp32/atexit_stubs.cpp | 20 +++++++++++++++++++ esphome/components/libretiny/__init__.py | 3 +++ esphome/components/libretiny/atexit_stubs.cpp | 20 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 esphome/components/esp32/atexit_stubs.cpp create mode 100644 esphome/components/libretiny/atexit_stubs.cpp diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index c825deaefd..73078dc19f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2925,6 +2925,11 @@ async def to_code(config): # produce reproducible outputs and downstream tooling can reuse artifacts. add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) + # Static destructors never run, so skip registering them. See atexit_stubs.cpp. + # --undefined: libsrc.a is scanned before the IDF libraries that also register them. + cg.add_build_flag("-Wl,--wrap=__cxa_atexit") + cg.add_build_flag("-Wl,--undefined=__wrap___cxa_atexit") + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") diff --git a/esphome/components/esp32/atexit_stubs.cpp b/esphome/components/esp32/atexit_stubs.cpp new file mode 100644 index 0000000000..1230a56681 --- /dev/null +++ b/esphome/components/esp32/atexit_stubs.cpp @@ -0,0 +1,20 @@ +/* + * Linker wrap stub for __cxa_atexit(). + * + * GCC registers the destructor of every global and function local static + * object with __cxa_atexit(). The firmware never returns from main or calls + * exit(), so these destructors never run, but the first registration links + * newlib's exit handler tables (about 400 bytes of RAM on ESP32). + */ + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP32 diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 5d630fec6c..433c8aee74 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -491,6 +491,9 @@ async def component_to_code(config): # Not enabled on RTL87xx/LN882x — costs more heap than it saves there. cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1") + # Static destructors never run, so skip registering them. See atexit_stubs.cpp. + cg.add_build_flag("-Wl,--wrap=__cxa_atexit") + # LN882x: a zero-size allocation must not trip the SDK's assert (see ln882x_zero_malloc.c). if config[CONF_COMPONENT_ID] == COMPONENT_LN882X: cg.add_build_flag("-Wl,--wrap=pvPortMalloc") diff --git a/esphome/components/libretiny/atexit_stubs.cpp b/esphome/components/libretiny/atexit_stubs.cpp new file mode 100644 index 0000000000..1d237e7f05 --- /dev/null +++ b/esphome/components/libretiny/atexit_stubs.cpp @@ -0,0 +1,20 @@ +/* + * Linker wrap stub for __cxa_atexit(). + * + * GCC registers the destructor of every global and function local static + * object with __cxa_atexit(). The firmware never returns from main or calls + * exit(), so these destructors never run, but the first registration links + * newlib's exit handler tables. + */ + +#include "esphome/core/defines.h" + +#ifdef USE_LIBRETINY + +namespace esphome::libretiny {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_LIBRETINY From 28b012389d0f22bd947aab2a57a5d5ad02fc8d02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:10:18 -1000 Subject: [PATCH 094/268] [ci] Keep bus group partners on the same runner (#20431) --- script/split_components_for_ci.py | 22 ++++++++++++++++---- tests/script/test_split_components_for_ci.py | 18 ++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index 26907936c1..663f2c6d5d 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -262,8 +262,9 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s """Spread items over enough runners to stay near target_seconds each. The runner count comes from the total estimate with every grouped build - counted once. Heaviest items go first, each to the runner that ends up - lightest, so grouped components follow the builds they can join. + counted once. Heaviest items go first, each to the runner it adds the + fewest seconds to among those ending near the lightest, so grouped + components follow the builds they can join. """ if not items: return [] @@ -274,10 +275,23 @@ def balance_batches(items: list[_BatchItem], target_seconds: int) -> list[list[s total = whole.seconds count = min(len(items), max(1, math.ceil(total / target_seconds))) batches = [_Batch() for _ in range(count)] + # Among the runners ending within slack of the lightest, the one the item + # adds the fewest seconds to wins, so a component lands with the bus + # group partners it shares builds with; placed away from them it builds + # alone on every platform they only share there. + slack = target_seconds // 4 for item in sorted(items, key=lambda i: (-i.standalone_seconds(), i.component)): - min(batches, key=lambda b: (b.seconds + b.added_seconds(item), b.seconds)).add( - item + costs = [(batch, batch.added_seconds(item)) for batch in batches] + ceiling = min(batch.seconds + added for batch, added in costs) + slack + batch, _ = min( + ( + (batch, added) + for batch, added in costs + if batch.seconds + added <= ceiling + ), + key=lambda cost: (cost[1], cost[0].seconds + cost[1]), ) + batch.add(item) return [batch.components for batch in batches if batch.components] diff --git a/tests/script/test_split_components_for_ci.py b/tests/script/test_split_components_for_ci.py index 4da644d679..718d754f01 100644 --- a/tests/script/test_split_components_for_ci.py +++ b/tests/script/test_split_components_for_ci.py @@ -249,6 +249,24 @@ def test_make_item_reads_tests_dir(tmp_path: Path) -> None: assert item.own_seconds == 55 +def test_balance_batches_keeps_partners_together() -> None: + """Two components sharing a build land on one runner even when the + lightest runner is the other one.""" + share = { + ("i2c", "esp32-idf"): split_components_for_ci._GroupedShare(55, frozenset()), + ("i2c", "esp8266-ard"): split_components_for_ci._GroupedShare(30, frozenset()), + } + items = [ + split_components_for_ci._BatchItem("partner_a", 0, dict(share)), + split_components_for_ci._BatchItem("partner_b", 0, dict(share)), + split_components_for_ci._BatchItem("alone", 80), + ] + + batches = split_components_for_ci.balance_batches(items, target_seconds=120) + + assert sorted(batches) == [["alone"], ["partner_a", "partner_b"]] + + def test_balance_batches_empty() -> None: """No components means no runners.""" assert split_components_for_ci.balance_batches([], 600) == [] From 58815575efa79d324d83736187316ea24f0a4e66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 09:24:32 -1000 Subject: [PATCH 095/268] [core] Pass a single condition to actions directly instead of wrapping it in a group (#19223) --- esphome/automation.py | 57 ++----------------- tests/component_tests/automation/__init__.py | 0 .../automation/config/condition_groups.yaml | 44 ++++++++++++++ .../automation/test_condition_groups.py | 25 ++++++++ 4 files changed, 75 insertions(+), 51 deletions(-) create mode 100644 tests/component_tests/automation/__init__.py create mode 100644 tests/component_tests/automation/config/condition_groups.yaml create mode 100644 tests/component_tests/automation/test_condition_groups.py diff --git a/esphome/automation.py b/esphome/automation.py index b69edd04a1..b1a35bf4c0 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -672,52 +672,20 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition) @register_condition("and", AndCondition, validate_condition_list) -async def and_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - -@register_condition("or", OrCondition, validate_condition_list) -async def or_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - @register_condition("all", AndCondition, validate_condition_list) -async def all_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - +@register_condition("or", OrCondition, validate_condition_list) @register_condition("any", OrCondition, validate_condition_list) -async def any_condition_to_code( +@register_condition("xor", XorCondition, validate_condition_list) +async def condition_group_to_code( config: ConfigType, condition_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) + # A group of one is that condition for and, or and xor; skip the wrapper. + if len(conditions) == 1: + return conditions[0] return cg.new_Pvariable( condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions ) @@ -734,19 +702,6 @@ async def not_condition_to_code( return cg.new_Pvariable(condition_id, template_arg, condition) -@register_condition("xor", XorCondition, validate_condition_list) -async def xor_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - @register_condition("lambda", LambdaCondition, cv.returning_lambda) async def lambda_condition_to_code( config: ConfigType, diff --git a/tests/component_tests/automation/__init__.py b/tests/component_tests/automation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/automation/config/condition_groups.yaml b/tests/component_tests/automation/config/condition_groups.yaml new file mode 100644 index 0000000000..9624531862 --- /dev/null +++ b/tests/component_tests/automation/config/condition_groups.yaml @@ -0,0 +1,44 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +logger: + +binary_sensor: + - platform: template + id: single_and + on_press: + - if: + condition: + - lambda: return true; + then: + - logger.log: single and + - platform: template + id: single_or + on_press: + - if: + any: + - lambda: return true; + then: + - logger.log: single or + - platform: template + id: double_and + on_press: + - if: + condition: + - lambda: return true; + - lambda: return false; + then: + - logger.log: double and + - platform: template + id: single_xor + on_press: + - if: + condition: + xor: + - lambda: return true; + then: + - logger.log: single xor diff --git a/tests/component_tests/automation/test_condition_groups.py b/tests/component_tests/automation/test_condition_groups.py new file mode 100644 index 0000000000..effe7b5b0b --- /dev/null +++ b/tests/component_tests/automation/test_condition_groups.py @@ -0,0 +1,25 @@ +"""Tests for and/or condition group codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_single_condition_groups_are_unwrapped( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A group of one condition is passed to the action directly.""" + main_cpp = generate_main(component_config_path("condition_groups.yaml")) + + 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\}\);", + main_cpp, + ) + assert group is not None + assert f"IfAction({group.group(1)});" in main_cpp + # xor of one condition equals that condition, so it is unwrapped too. + assert "IfAction(lambdacondition_id_5);" in main_cpp + assert "XorCondition<" not in main_cpp From 34eca948c459732aa3153e1e687fe934830242fe Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:06:10 -1000 Subject: [PATCH 096/268] Bump bundled esphome-device-builder to 1.23.0 (#20446) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0282011f50..9edbd6e4f9 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.22.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0 RUN \ platformio settings set enable_telemetry No \ From 3d248496ab5b13f72c7ab7383b9b27d639af93da Mon Sep 17 00:00:00 2001 From: Ali Alaei <49282631+aalaei@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:50:50 +0200 Subject: [PATCH 097/268] [zephyr_mcumgr] Enable MCUMGR_GRP_OS for UART transport, not just BLE (#18319) Co-authored-by: tomaszduda23 --- esphome/components/zephyr_mcumgr/ota/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index d4f84e30f2..ed7069a540 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -147,14 +147,15 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("MCUMGR_MGMT_NOTIFICATION_HOOKS", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG_STATUS_HOOKS", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK", True) + # OS management group (reset, MCUMGR_PARAMETERS query, etc.) isn't transport-specific -- + # every mcumgr transport needs it, not just BLE. + zephyr_add_prj_conf("MCUMGR_GRP_OS", True) + zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) transport = config[CONF_TRANSPORT] if transport[CONF_BLE]: zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT", True) zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT_REASSEMBLY", True) - zephyr_add_prj_conf("MCUMGR_GRP_OS", True) - zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) - zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True) request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: From f2395f9001134385ba37027b77e235572652cbbc Mon Sep 17 00:00:00 2001 From: Zhaoqi Xu Date: Sat, 10 Oct 2026 09:53:38 +0800 Subject: [PATCH 098/268] [heatpumpir] Fix fan speeds for mitsubishi_msc protocol (#20460) --- esphome/components/heatpumpir/heatpumpir.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 502f83cd5d..838c8ba54a 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -187,15 +187,18 @@ void HeatpumpIRClimate::transmit_state() { swing_h_cmd = HDIR_SWING; } + // MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto, + // so low/medium/high have to start at FAN_1 there instead of FAN_2. + const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC; switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { case climate::CLIMATE_FAN_LOW: - fan_speed_cmd = FAN_2; + fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2; break; case climate::CLIMATE_FAN_MEDIUM: - fan_speed_cmd = FAN_3; + fan_speed_cmd = three_speed_fan ? FAN_2 : FAN_3; break; case climate::CLIMATE_FAN_HIGH: - fan_speed_cmd = FAN_4; + fan_speed_cmd = three_speed_fan ? FAN_3 : FAN_4; break; case climate::CLIMATE_FAN_AUTO: default: From 3ae52e21cfa9bf5237dc4c1d91ba57bd21b91b4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 9 Oct 2026 16:38:05 -1000 Subject: [PATCH 099/268] [usb_host] Warn instead of failing when a device duplicates a usb_uart entry (#20373) --- esphome/components/usb_host/__init__.py | 53 ++++++++++---- tests/unit_tests/components/test_usb_host.py | 75 +++++++++++++++++++- 2 files changed, 113 insertions(+), 15 deletions(-) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 94532958ed..317cec8236 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,4 +1,5 @@ -from itertools import combinations +import itertools +import logging import esphome.codegen as cg from esphome.components.const import CONF_MANUFACTURER @@ -22,6 +23,8 @@ import esphome.final_validate as fv from esphome.helpers import cpp_u16string_escape from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["bytebuffer"] CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["esp32"] @@ -71,15 +74,19 @@ def usb_device_schema( ) -def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: +def _clients_overlap(first: ConfigType, second: ConfigType) -> bool: # Two entries overlap when no field they both constrain tells them apart - for first, second in combinations(configs, 2): - for key, wildcard in _FILTER_WILDCARDS.items(): - a = first.get(key) - b = second.get(key) - if wildcard not in (a, b) and a != b: - break - else: + for key, wildcard in _FILTER_WILDCARDS.items(): + a = first.get(key) + b = second.get(key) + if wildcard not in (a, b) and a != b: + return False + return True + + +def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: + for first, second in itertools.combinations(configs, 2): + if _clients_overlap(first, second): raise cv.Invalid( f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}" ) @@ -87,11 +94,29 @@ def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: def _final_validate(config: ConfigType) -> ConfigType: - # Every USB client on the bus, whichever component configured it: any two could - # otherwise open the same device - clients = list(config.get(CONF_DEVICES) or ()) - clients.extend(fv.full_config.get().get("usb_uart") or ()) - validate_usb_clients(clients) + # Any two overlapping clients could otherwise open the same device + devices = config.get(CONF_DEVICES) or [] + uarts = fv.full_config.get().get("usb_uart") or [] + validate_usb_clients(devices) + validate_usb_clients(uarts) + # Remove before 2027.10.0, then pool devices and uarts in one validate_usb_clients call + for device, uart in itertools.product(devices, uarts): + if not _clients_overlap(device, uart): + continue + # The device matches nothing the usb_uart entry does not already claim + redundant = all( + uart.get(key) in (wildcard, device.get(key)) + for key, wildcard in _FILTER_WILDCARDS.items() + ) + _LOGGER.warning( + "usb_host device '%s' matches the same USB device as usb_uart '%s'; %s. " + "This will be an error in 2027.10.0", + device[CONF_ID], + uart[CONF_ID], + "remove it from usb_host devices" + if redundant + else "narrow its filter so it no longer matches that device", + ) return config diff --git a/tests/unit_tests/components/test_usb_host.py b/tests/unit_tests/components/test_usb_host.py index 1a1df68f4d..8d572dba96 100644 --- a/tests/unit_tests/components/test_usb_host.py +++ b/tests/unit_tests/components/test_usb_host.py @@ -1,9 +1,12 @@ """Tests for usb_host device matching validation.""" +import logging + import pytest -from esphome.components.usb_host import validate_usb_clients +from esphome.components.usb_host import _final_validate, validate_usb_clients import esphome.config_validation as cv +import esphome.final_validate as fv from esphome.types import ConfigType @@ -109,3 +112,73 @@ def test_every_pair_is_compared_not_just_neighbours() -> None: ] with pytest.raises(cv.Invalid, match="a, c"): validate_usb_clients(configs) + + +def _run_final_validate( + devices: list[ConfigType], uarts: list[ConfigType] +) -> ConfigType: + config = {"id": "usb_host", "devices": devices} + token = fv.full_config.set({"usb_host": config, "usb_uart": uarts}) + try: + return _final_validate(config) + finally: + fv.full_config.reset(token) + + +def test_device_duplicating_usb_uart_warns(caplog: pytest.LogCaptureFixture) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4001}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "'device_0'" in caplog.text + assert "'uart_0'" in caplog.text + assert "remove it from usb_host devices" in caplog.text + + +def test_wildcard_device_covering_usb_uart_suggests_narrowing( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "narrow its filter" in caplog.text + assert "remove it" not in caplog.text + + +def test_disjoint_device_and_usb_uart_do_not_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4002}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0x4001}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert caplog.text == "" + + +_DUPLICATE_CLIENTS = [ + {"id": "a", "vid": 0x303A, "pid": 0x4001}, + {"id": "b", "vid": 0x303A, "pid": 0x4001}, +] + + +@pytest.mark.parametrize( + ("devices", "uarts"), + [([*_DUPLICATE_CLIENTS], []), ([], [*_DUPLICATE_CLIENTS])], + ids=["devices", "uarts"], +) +def test_overlap_within_one_component_is_rejected( + devices: list[ConfigType], uarts: list[ConfigType] +) -> None: + with pytest.raises(cv.Invalid, match="a, b"): + _run_final_validate(devices, uarts) + + +def test_device_inside_broader_usb_uart_suggests_removing( + caplog: pytest.LogCaptureFixture, +) -> None: + devices = [{"id": "device_0", "vid": 0x303A, "pid": 0x4001}] + uarts = [{"id": "uart_0", "vid": 0x303A, "pid": 0}] + with caplog.at_level(logging.WARNING): + _run_final_validate(devices, uarts) + assert "remove it from usb_host devices" in caplog.text From 21804a75debf9b96d45890011e4a3f5b708ba217 Mon Sep 17 00:00:00 2001 From: Mathieu Rene Date: Sun, 11 Oct 2026 18:41:14 -0400 Subject: [PATCH 100/268] [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 44eb546850..68ee263e7c 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 ef7c5cc8e9..1f97c57780 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 4d3e8cd6c3..c7e8a60298 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 8c55546ce6..a1a91e9c67 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 c9e49b8e8fbbdf63a7ccbb2a053eebde6e462e1c 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 101/268] [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 | 3 +- .../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, 373 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 b5138c0612..3fe111b628 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -568,7 +568,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 67aad11d0f..5a6a01f669 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -4,7 +4,8 @@ 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 teleinfo_ns = cg.esphome_ns.namespace("teleinfo") diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index b51d4cb795..6a23836c8a 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 0000000000..e69de29bb2 diff --git a/tests/component_tests/teleinfo/test_sensor.py b/tests/component_tests/teleinfo/test_sensor.py new file mode 100644 index 0000000000..a8f9420dea --- /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 dcb2cb14bd..7f73bca62c 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 97154f9e5f656285a246dc53d242b0c96570bce4 Mon Sep 17 00:00:00 2001 From: jabla Date: Sat, 10 Oct 2026 19:28:40 +0200 Subject: [PATCH 102/268] [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 effb817f35..c09cb73f8c 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 hashlib @@ -125,6 +126,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(): @@ -228,6 +231,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) @@ -311,6 +316,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() @@ -809,6 +858,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 854c39a9ce..202ddd970c 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. @@ -1573,3 +1582,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 654afb536ec12dbb5d75857202b31ae4fff3cafa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 07:39:49 -1000 Subject: [PATCH 103/268] [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 44369136a0..b0ba766392 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 f1e53e1ccb..3a8587891b 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 a2ee244a29..c74262fdee 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 2ea4ffabe6..4c60b4e476 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 9e40698c91..c3936ee65a 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 e7f9b96af2..d51110efad 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 93c8796ae872dffe71220cf4f3a53e73d2d92cc7 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 10 Oct 2026 19:49:35 +0200 Subject: [PATCH 104/268] [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 631959530e..bc535ebfdb 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -834,7 +834,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 897b2f16a98856170bf6ebfe13a0db2e59c5d05c 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 105/268] [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 16b5f544f7..89a6ff4234 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 fb22a26b55..70befc9049 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 4bae89bd4a47fa9d489a8feda2cf6ef32d5c8e83 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Sat, 10 Oct 2026 21:17:01 +0000 Subject: [PATCH 106/268] [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 bc535ebfdb..f8649f968e 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -731,13 +731,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: @@ -752,7 +752,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)) @@ -761,7 +761,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 f74a49ea89..5edcddfd32 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 cafeda6478..9115e21874 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 0000000000..c0b5e202f7 --- /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 375fbd829e432d687bdff94dfbf533c47907196b 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 107/268] 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 9edbd6e4f9..c9aa3dba62 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 ec58e864f55da38908fcaf3b06d2ec0802a8342a Mon Sep 17 00:00:00 2001 From: fakuivan Date: Sat, 10 Oct 2026 22:07:36 -0300 Subject: [PATCH 108/268] [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 b7de235dd1..00435bf18e 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -19,6 +19,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: @@ -86,8 +89,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), @@ -108,9 +116,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 c5ba59a645..114230500d 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -67,16 +67,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; @@ -100,6 +102,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 1bc4d6e345..b5ccd9911a 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -78,6 +78,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); /** @@ -96,8 +97,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 3ba3564608..b1a2442d82 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 41a9fca8810e219624de1ce6028189bd9a8501fb 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 109/268] [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 c09cb73f8c..5b32646e04 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -354,7 +354,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}")) @@ -659,6 +663,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 202ddd970c..9f92106676 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 58e03a875f054dd3b73ede0b9faaa847abe4a3bf 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 110/268] [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 17d3d6c099..97405c4540 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 3c7ce71d6f7477b905d8abf58c5144583fab1859 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 02:47:12 -1000 Subject: [PATCH 111/268] [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 33d32f4c07..d2e1603bd1 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 bd2e8d184d4580ceca8fad3e40440dfda7e4ee62 Mon Sep 17 00:00:00 2001 From: sh1der Date: Sun, 11 Oct 2026 22:24:50 +0200 Subject: [PATCH 112/268] [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 e59f186521..da843a008a 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 c261629573..a85878e764 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 9bec3751d6..b48d32b900 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 5d75828cebe1205eb245a87bb33ffd369383f57a 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 113/268] [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 a0dc7ae4cb..0d095b5384 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 { static const char *const 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 @@ -686,6 +693,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 b231bab33e..23cecfc986 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 0000000000..3853bf3e8d --- /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 7c0f63ea200f9c7249c0561a2f35c0d9dd6dfd7f 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 114/268] [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 97b27af498..f00ad7f4af 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 f5921168e0..8820d2a63a 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 @@ -490,6 +491,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 8762cbe711..69def222da 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 0000000000..56cfa10d51 --- /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 0000000000..17f553996e --- /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 fb1ef29d54ba7220f8084b304e8b94444ded864d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 11:56:21 -1000 Subject: [PATCH 115/268] [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 | 77 +++-- .../components/uart/uart_component_esp8266.h | 21 +- .../uart/test_software_serial_rx_decoder.cpp | 285 ++++++++++++++++++ 4 files changed, 534 insertions(+), 28 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 0000000000..d80dbf1a85 --- /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 0f6d7f6577..b81c0af85f 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -15,7 +15,13 @@ namespace esphome::uart { -static const char *const TAG = "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 0098e752b2..3d4a2ba85b 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 0000000000..b65e8a659f --- /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 2727d356552a77df95019c4db308dfadd7c719a6 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 116/268] [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 337fa352c1..44bc99ad72 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -10,8 +10,8 @@ namespace esphome::uart { static const char *const 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 8f143b7977..2dfbf9c163 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 63b5631564..bf9ab2dfd7 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 293cb9e786..e1c1a7143b 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 a632b4e38d..d5cb65dfbb 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, @@ -38,7 +43,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 00c5e0b069..ed5a034b82 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 5551abe1a1..e920da6c26 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 fcebf0fbd9..e10da78aaf 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 db177fd308..36107c093c 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 86dfa6d729..f61642566c 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 c6ae0b4eef..4e52fb60e2 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 60402614cdaa5d03cf1b014a90d102141b5b6c0d Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 12 Oct 2026 00:15:23 +0200 Subject: [PATCH 117/268] [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 cedb9b3c9b..47526d99bd 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, @@ -462,6 +464,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 74f81df2e9..0572e591cf 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 3f14d0a62d..4eb20d3da2 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -2,6 +2,7 @@ #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" #include "esphome/core/application.h" +#include namespace esphome::zigbee { @@ -10,12 +11,14 @@ static const char *const 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, @@ -26,7 +29,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); }); } @@ -39,8 +41,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) { @@ -63,8 +69,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 87187c539118088be5ff588191fcf51ade05d553 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 118/268] [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 76b82810a4..77b5afe818 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 986017a138..0dd2869caa 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -14,26 +14,68 @@ namespace esphome::api { static const char *const 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 7d5a566b55..6bdc1654bc 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 314b907e4d..e3f3faabf6 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, @@ -46,6 +48,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 @@ -101,6 +107,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( @@ -109,6 +130,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( @@ -119,6 +144,7 @@ WIZARD_INPUT_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_ID, CONF_ENTITY), + _validate_multiple, ) WIZARD_PAGE_SCHEMA = cv.All( @@ -243,6 +269,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 @@ -374,6 +402,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 @@ -387,7 +419,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": ["..."]}]}]}]} @@ -399,6 +431,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. """ @@ -444,14 +484,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 12f61467d6..f3e54660f7 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -275,6 +275,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 276f42d624..8de3a2018c 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( @@ -940,3 +960,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 bd84c8d9bb..79feb398bd 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 5e1936a042..d658da1c1f 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 033c8b627e..54aeb4b4ed 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 9e580db434d665e0bf13328f411dd58d64e9c4ef 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 119/268] [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 b81c0af85f..0819a1bfb0 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 bd4b32873a6b94c9657df836988ea6c5cb297f54 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:52:33 +1300 Subject: [PATCH 120/268] Bump version to 2026.10.0b3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 455a83a021..a72069f844 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.10.0b2 +PROJECT_NUMBER = 2026.10.0b3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 5271266947..15dcce03ff 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.10.0b2" +__version__ = "2026.10.0b3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 0df7b0b3662fda1a0f89c124b561512d86f28089 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 05:08:38 -1000 Subject: [PATCH 121/268] [core] Move zstd_module from the api wizard to esphome.helpers (#20342) --- esphome/components/api/wizard.py | 16 ++++++---------- esphome/helpers.py | 10 ++++++++++ requirements.txt | 2 +- tests/component_tests/api/test_wizard.py | 23 ++++------------------- tests/unit_tests/test_helpers.py | 17 +++++++++++++++++ 5 files changed, 38 insertions(+), 30 deletions(-) diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index e3f3faabf6..e4656e088a 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -6,7 +6,6 @@ Home Assistant shows the wizard when the device is added. See api_wizard.h for t from collections.abc import Callable import importlib import json -from types import ModuleType from typing import Any from esphome import automation @@ -29,7 +28,12 @@ from esphome.const import ( ) from esphome.core import CORE, ID import esphome.final_validate as fv -from esphome.helpers import fnv1_hash, fnv1_hash_object_id, fnv1a_32bit_hash +from esphome.helpers import ( + fnv1_hash, + fnv1_hash_object_id, + fnv1a_32bit_hash, + zstd_module, +) from esphome.types import ConfigType API_DOMAIN = "api" @@ -379,14 +383,6 @@ automation.register_apply_condition( ) -def zstd_module() -> ModuleType: - """The zstd module: the standard library one from Python 3.14, otherwise the backport.""" - try: - return importlib.import_module("compression.zstd") - except ImportError: - return importlib.import_module("backports.zstd") - - def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: """An entity of the device that the page shows, keyed as ListEntitiesResponse keys it.""" _, declaration = _wizard_input_declaration(config, conf[CONF_ID]) diff --git a/esphome/helpers.py b/esphome/helpers.py index 5ec46be67e..1f9b140797 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Iterable, MutableMapping from contextlib import suppress +import importlib import ipaddress import logging import os @@ -10,6 +11,7 @@ import platform import re import stat import sys +from types import ModuleType from typing import TYPE_CHECKING, TextIO from esphome.const import __version__ as ESPHOME_VERSION @@ -827,3 +829,11 @@ def docs_url(path: str) -> str: path = path.removeprefix("/") return docs_format.format(path=path) + + +def zstd_module() -> ModuleType: + """The zstd module: the standard library one from Python 3.14, otherwise the backport.""" + try: + return importlib.import_module("compression.zstd") + except ImportError: + return importlib.import_module("backports.zstd") diff --git a/requirements.txt b/requirements.txt index f429d84d52..b7df6ca4fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -backports.zstd==1.7.0; python_version < "3.14" # api wizard; compression.zstd is in the standard library from 3.14 +backports.zstd==1.7.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 pillow==12.3.0 resvg-py==0.5.0 freetype-py==2.5.1 diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index 8de3a2018c..f55a6974e7 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -14,7 +14,7 @@ from esphome.components.api import wizard from esphome.components.homeassistant.switch import SUPPORTED_DOMAINS as SWITCH_DOMAINS from esphome.config import load_config from esphome.core import CORE -from esphome.helpers import fnv1_hash, fnv1a_32bit_hash +from esphome.helpers import fnv1_hash, fnv1a_32bit_hash, zstd_module from tests.component_tests.helpers import get_define_value ESP32_HEADER = """ @@ -633,7 +633,7 @@ def test_text_select_and_button_default_to_their_domains( tmp_path: Path, generate_main: Callable[[str | Path], str] ) -> None: main_cpp = generate_main(write_input_config(tmp_path, ESP32_HEADER)) - document = json.loads(wizard.zstd_module().decompress(blob_in(main_cpp))) + document = json.loads(zstd_module().decompress(blob_in(main_cpp))) filters = { entry["key"]: entry.get("entity_filters") for page in document["pages"] @@ -753,7 +753,7 @@ def test_the_blob_is_the_exact_json_document( ) blob = blob_in(main_cpp) - text = wizard.zstd_module().decompress(blob).decode("utf-8") + text = zstd_module().decompress(blob).decode("utf-8") expected = { "version": 1, "pages": [ @@ -808,7 +808,7 @@ def test_the_blob_is_deterministic_and_the_same_on_every_platform( ) # Compressing the same document again gives the same bytes document = wizard.wizard_document(CORE.config["api"]["wizard"], CORE.config) - again = wizard.zstd_module().compress( + again = zstd_module().compress( json.dumps( document, separators=(",", ":"), sort_keys=True, ensure_ascii=False ).encode("utf-8"), @@ -879,21 +879,6 @@ def test_an_entity_without_a_name_cannot_be_in_the_wizard(tmp_path: Path) -> Non assert any("has no name of its own" in error for error in errors), errors -def test_zstd_falls_back_to_the_backport(monkeypatch: pytest.MonkeyPatch) -> None: - """Before Python 3.14 the standard library has no zstd, so the backport is used.""" - backport = object() - - def import_module(name: str) -> object: - if name == "compression.zstd": - raise ImportError(name) - assert name == "backports.zstd" - return backport - - monkeypatch.setattr(wizard.importlib, "import_module", import_module) - - assert wizard.zstd_module() is backport - - def test_a_wizard_too_big_for_one_message_is_rejected(tmp_path: Path) -> None: # Random text does not compress, so this needs more than the limit even compressed rng = random.Random(1) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 8dae1e87b2..764f160cbc 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -1285,3 +1285,20 @@ def test_get_usable_cpu_count_sources() -> None: mock_os_unknown = types.SimpleNamespace(cpu_count=lambda: None) with patch("esphome.helpers.os", mock_os_unknown): assert helpers.get_usable_cpu_count() == 1 + + +def test_zstd_module_falls_back_to_the_backport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Before Python 3.14 the standard library has no zstd, so the backport is used.""" + backport = object() + + def import_module(name: str) -> object: + if name == "compression.zstd": + raise ImportError(name) + assert name == "backports.zstd" + return backport + + monkeypatch.setattr(helpers.importlib, "import_module", import_module) + + assert helpers.zstd_module() is backport From 427e0c90641f2f919c27d372c31fbcb4403619ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 13:50:50 -1000 Subject: [PATCH 122/268] [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 51a5e01210..c44a323f65 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 898aa94543..e19d76a64a 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 123/268] [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 e9e848da6f..8b1db61f46 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 0000000000..2390d2a073 --- /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 124/268] [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 6fd5f481e7..077dd03ccc 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 0000000000..eb23206b98 --- /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 a1066b9761..09b29dd7ef 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 b14b6c4cc1..caf75e19be 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 d219296ca5..28abdb2218 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 0000000000..3e1e42197d --- /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 0000000000..c56d0d96fa --- /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 020303d455..49f2629baf 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 9702134e8c..f86177c0f9 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 c764eae42b..1e5f1f456c 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 b148b07716..cd06d8a83d 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 125/268] [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 a880169fb2..1911218b1f 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 caa950218e..fff8eb897f 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 335fbd164b..093f2904aa 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 0000000000..f14bc75f30 --- /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 0000000000..aafab1f2c8 --- /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 c52eb41ca4..90734b72f2 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 0000000000..e96d8b9d01 --- /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 4dadec6a01..55f489505c 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 776a03e680..f168a825d3 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 0000000000..d6233f0230 --- /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 0000000000..bd0f817dd1 --- /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 126/268] [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 fce8519130..6e7e4eda33 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 677eeee7f2..a1ab78bfbf 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 127/268] [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 0000000000..6ef8b1fad7 --- /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 f8614b17a3..118f844c84 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 128/268] [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 54f111e4af..5d43162241 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 35326d77d5..df9c1720ee 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 2250b9d508..5eea8060ff 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 e49531c80b..08cd13e3d2 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 581727b326..0875966071 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 0000000000..bbd131037d --- /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 129/268] [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 1911218b1f..eca57bca8a 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 a309c124ee..901950d363 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 0000000000..66e0fe4e7f --- /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 0000000000..a653b5137e --- /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 130/268] [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 a2c181a5ae..89fe5f8b1c 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 68696118ea..424fcf84e8 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 0000000000..bdd4cd6630 --- /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 0000000000..4eefa55b20 --- /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 131/268] [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 4db01a2174..9ed36de0d5 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 f538c0b5e5..f105c1d7f7 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 9c9fed6601..1196669662 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 adbfdf306e..69c9605a67 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 6fdd197c95..9529e44800 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 132/268] [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 1b06348e2c..f8f31d81df 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 17436d33aa..14204e97aa 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 c40130634f..b09d979141 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 3170c7a8dd..671e11e7d2 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 f57327e33f..203ee52ac8 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 0000000000..e69de29bb2 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 0000000000..8e849d4a78 --- /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 0000000000..30e653bc70 --- /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 0000000000..0bc99e1a8e --- /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 133/268] [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 3cc4dc7009..8a954b181e 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 abfe1b027a..5d24759c2d 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 b7d46eae22..e9c0ae75fd 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 0000000000..e69de29bb2 diff --git a/tests/component_tests/thermostat/config/presets.yaml b/tests/component_tests/thermostat/config/presets.yaml new file mode 100644 index 0000000000..956562ff63 --- /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 0000000000..129cb1587f --- /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 134/268] [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 75a099a3c4..d37b2f4020 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 75a3399379..1571a6077b 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 b7a3fae1a5..905f5b7a0d 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 228313e2d8..7810dba9f5 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 5096d39f28..1ffd403434 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 e5463847fa..d90b2a28fd 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 0000000000..69f1a17dc8 --- /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 21a12d9cf2..e3006d2c69 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 9a470d8a08..bcae7125bf 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 b513c43234..01994e2d7a 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 135/268] [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 fea5769050..2f90cbfcbb 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 f105c1d7f7..9611228ba1 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 60e20aa193..109a22c7bc 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 2b404442a4..1440e07f76 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 510ee94c6a..01949f45df 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 e9c70f7a2e..959ce77c7a 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 69def222da..09cde1261d 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 effe7b5b0b..5d9f9b828e 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 8154471e47..ceecd2f122 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 0000000000..89f1a80ce2 --- /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 0000000000..adf1498e37 --- /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 136/268] [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 5eea8060ff..d1ac806b5f 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 e5a9fa7c7b..b134c8af9f 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 95fe21271d..821d8264dc 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 b55207cb46..99b5ee548d 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 47f45d4899..903f945fd3 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 290bbed5d3..3288ceeee2 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 6b6aa273f7..1af497ffcf 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 c924c7d4e5..96d8833d7a 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 d1c6773363..7a34a20583 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 82bc592702..e77e37cd08 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 6d446fd6a6..14709d4490 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 0000000000..e69de29bb2 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 0000000000..e93ab4754d --- /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 0000000000..2ba341136a --- /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 From 4ba5afaa1b99e514ade6f099b36774fb159f7118 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:37:17 +1100 Subject: [PATCH 137/268] [core] Move CONF_ON_STOP to the shared component constants (#20548) Co-authored-by: Claude Opus 5.5 --- esphome/components/const/__init__.py | 1 + esphome/components/improv_ble/__init__.py | 2 +- esphome/components/lvgl/animation.py | 3 +-- esphome/components/lvgl/defines.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index c44a323f65..78778553aa 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -50,6 +50,7 @@ CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" +CONF_ON_STOP = "on_stop" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index 9dfc700f74..e7d4be8cc3 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor, improv_base, output +from esphome.components.const import CONF_ON_STOP import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, PLATFORM_ESP32 from esphome.core import CORE @@ -38,7 +39,6 @@ CONF_BLE_SERVER_ID = "ble_server_id" CONF_IDENTIFY_DURATION = "identify_duration" CONF_ON_PROVISIONED = "on_provisioned" CONF_ON_PROVISIONING = "on_provisioning" -CONF_ON_STOP = "on_stop" CONF_STATUS_INDICATOR = "status_indicator" CONF_WIFI_TIMEOUT = "wifi_timeout" diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py index bab413f04d..f1c3887d5c 100644 --- a/esphome/components/lvgl/animation.py +++ b/esphome/components/lvgl/animation.py @@ -15,11 +15,10 @@ from esphome.const import ( ) from esphome.cpp_generator import MockObj, TemplateArguments -from ..const import CONF_LOOP +from ..const import CONF_LOOP, CONF_ON_STOP from .defines import ( CONF_AUTO_START, CONF_LVGL_ID, - CONF_ON_STOP, CONF_WIDGETS, LValidator, add_define, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 854ef0fb49..2858b41f71 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -788,7 +788,6 @@ CONF_ON_PAUSE = "on_pause" CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" -CONF_ON_STOP = "on_stop" CONF_OPA = "opa" CONF_NEXT = "next" CONF_PAD_ROW = "pad_row" From c38674bb7bf08cf9fcd1f471c01350592763cd9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:53:55 -1000 Subject: [PATCH 138/268] [climate] Build the on_state and on_control triggers with build_callback_automation (#19611) --- esphome/components/climate/__init__.py | 43 ++++++------------- esphome/components/climate/automation.h | 22 ---------- tests/components/climate/common-triggers.yaml | 25 +++++++++++ .../climate/test-triggers.esp32-idf.yaml | 3 ++ 4 files changed, 41 insertions(+), 52 deletions(-) delete mode 100644 esphome/components/climate/automation.h create mode 100644 tests/components/climate/common-triggers.yaml create mode 100644 tests/components/climate/test-triggers.esp32-idf.yaml diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 17aeb1467d..a59c03ed1e 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -46,7 +46,6 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC, CONF_TARGET_TEMPERATURE_STATE_TOPIC, CONF_TEMPERATURE_STEP, - CONF_TRIGGER_ID, CONF_VISUAL, CONF_WEB_SERVER, ) @@ -164,14 +163,6 @@ def visual_temperature_step(value: Any) -> ConfigType: ) -# Actions -StateTrigger = climate_ns.class_( - "StateTrigger", automation.Trigger.template(Climate.operator("ref")) -) -ControlTrigger = climate_ns.class_( - "ControlTrigger", automation.Trigger.template(ClimateCall.operator("ref")) -) - _CLIMATE_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -250,16 +241,8 @@ _CLIMATE_SCHEMA = ( cv.Optional(CONF_TARGET_HUMIDITY_STATE_TOPIC): cv.All( cv.requires_component("mqtt"), cv.publish_topic ), - cv.Optional(CONF_ON_CONTROL): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ControlTrigger), - } - ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger), - } - ), + cv.Optional(CONF_ON_CONTROL): automation.validate_automation(), + cv.Optional(CONF_ON_STATE): automation.validate_automation(), } ) ) @@ -288,6 +271,16 @@ def climate_schema( return _CLIMATE_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", [(Climate.operator("ref"), "x")] + ), + automation.CallbackAutomation( + CONF_ON_CONTROL, "add_on_control_callback", [(ClimateCall.operator("ref"), "x")] + ), +) + + @setup_entity("climate") async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: visual = config.get(CONF_VISUAL, {}) @@ -443,17 +436,7 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: ) ) - for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(Climate.operator("ref"), "x")], conf - ) - - for conf in config.get(CONF_ON_CONTROL, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(ClimateCall.operator("ref"), "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h deleted file mode 100644 index d6aba02f92..0000000000 --- a/esphome/components/climate/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "climate.h" - -namespace esphome::climate { - -class ControlTrigger final : public Trigger { - public: - ControlTrigger(Climate *climate) { - climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); }); - } -}; - -class StateTrigger final : public Trigger { - public: - StateTrigger(Climate *climate) { - climate->add_on_state_callback([this](Climate &x) { this->trigger(x); }); - } -}; - -} // namespace esphome::climate diff --git a/tests/components/climate/common-triggers.yaml b/tests/components/climate/common-triggers.yaml new file mode 100644 index 0000000000..b79d871f0f --- /dev/null +++ b/tests/components/climate/common-triggers.yaml @@ -0,0 +1,25 @@ +# on_state and on_control on a thermostat. +sensor: + - platform: template + id: climate_triggers_temperature + +climate: + - platform: thermostat + name: Climate Triggers + id: climate_triggers + sensor: climate_triggers_temperature + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + heat_action: + - logger.log: heating + idle_action: + - logger.log: idle + on_state: + - logger.log: + format: "Climate mode %d" + args: [x.mode] + on_control: + - lambda: |- + if (x.get_mode().has_value()) + ESP_LOGD("test", "Control mode %d", *x.get_mode()); diff --git a/tests/components/climate/test-triggers.esp32-idf.yaml b/tests/components/climate/test-triggers.esp32-idf.yaml new file mode 100644 index 0000000000..bbf01242bd --- /dev/null +++ b/tests/components/climate/test-triggers.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + climate: !include common.yaml + triggers: !include common-triggers.yaml From 028db614cab6232e0c60a3c7d3a1f2c6d01c95e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:54:09 -1000 Subject: [PATCH 139/268] [select] Build the on_value trigger with build_callback_automation (#19609) --- esphome/components/select/__init__.py | 26 ++++++++----------- esphome/components/select/automation.h | 11 -------- tests/components/select/common-triggers.yaml | 14 ++++++++++ .../select/test-triggers.esp32-idf.yaml | 3 +++ 4 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 tests/components/select/common-triggers.yaml create mode 100644 tests/components/select/test-triggers.esp32-idf.yaml diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 07f86c1330..7b9f616460 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_OPERATION, CONF_OPTION, CONF_OPTIONS, - CONF_TRIGGER_ID, CONF_WEB_SERVER, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority @@ -37,12 +36,6 @@ select_ns = cg.esphome_ns.namespace("select") Select = select_ns.class_("Select", cg.EntityBase) SelectPtr = Select.operator("ptr") -# Triggers -SelectStateTrigger = select_ns.class_( - "SelectStateTrigger", - automation.Trigger.template(cg.StringRef, cg.size_t), -) - # Conditions SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) @@ -63,11 +56,7 @@ _SELECT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSelectComponent), cv.GenerateID(): cv.declare_id(Select), - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SelectStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation(), } ) ) @@ -101,9 +90,16 @@ async def setup_select_core_(var, config, *, options: list[str]): cg.add(var.traits.set_options_static(table, len(options))) for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.StringRef, "x"), (cg.size_t, "i")], conf + # The callback carries the index; the automation gets the option text as well. + parent = automation.parent_ref(var) + index = cg.RawExpression("index") + await automation.build_callback_automation( + var, + "add_on_state_callback", + [(cg.StringRef, "x"), (cg.size_t, "i")], + conf, + params=[(cg.size_t, "index")], + forward=[cg.StringRef(parent.option_at(index)), index], ) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index c2dc752ca5..9bef819e6d 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -6,17 +6,6 @@ namespace esphome::select { -class SelectStateTrigger final : public Trigger { - public: - explicit SelectStateTrigger(Select *parent) : parent_(parent) { - parent->add_on_state_callback( - [this](size_t index) { this->trigger(StringRef(this->parent_->option_at(index)), index); }); - } - - protected: - Select *parent_; -}; - template class SelectIsCondition final : public Condition { public: SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {} diff --git a/tests/components/select/common-triggers.yaml b/tests/components/select/common-triggers.yaml new file mode 100644 index 0000000000..bc3510950c --- /dev/null +++ b/tests/components/select/common-triggers.yaml @@ -0,0 +1,14 @@ +# on_value with both trigger arguments. +select: + - platform: template + name: Select Triggers + id: select_triggers + optimistic: true + options: + - one + - two + - three + on_value: + - logger.log: + format: "Selected %s at %zu" + args: [x.c_str(), i] diff --git a/tests/components/select/test-triggers.esp32-idf.yaml b/tests/components/select/test-triggers.esp32-idf.yaml new file mode 100644 index 0000000000..17b435f5a2 --- /dev/null +++ b/tests/components/select/test-triggers.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + select: !include common.yaml + triggers: !include common-triggers.yaml From 95de93bebb427baf254ad5c3b73231e1ecd8628c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:54:25 -1000 Subject: [PATCH 140/268] [fan] Build the on_state trigger with build_parent_callback_automation (#19612) --- esphome/components/fan/__init__.py | 44 +++++++------------ esphome/components/fan/automation.h | 10 ----- tests/components/fan/common-triggers.yaml | 35 +++++++++++++++ .../fan/test-triggers.esp8266-ard.yaml | 3 ++ 4 files changed, 54 insertions(+), 38 deletions(-) create mode 100644 tests/components/fan/common-triggers.yaml create mode 100644 tests/components/fan/test-triggers.esp8266-ard.yaml diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index ac5469fc42..cb54e89c3c 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -64,9 +64,6 @@ RESTORE_MODES = { # Actions CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) -FanStateTrigger = fan_ns.class_( - "FanStateTrigger", automation.Trigger.template(Fan.operator("ptr")) -) FanTurnOnTrigger = fan_ns.class_("FanTurnOnTrigger", automation.Trigger.template()) FanTurnOffTrigger = fan_ns.class_("FanTurnOffTrigger", automation.Trigger.template()) FanDirectionSetTrigger = fan_ns.class_( @@ -115,11 +112,7 @@ _FAN_SCHEMA = ( cv.Optional(CONF_SPEED_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FanStateTrigger), - } - ), + cv.Optional(CONF_ON_STATE): automation.validate_automation(), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FanTurnOnTrigger), @@ -221,6 +214,17 @@ def validate_preset_modes(value): return value +# The edge and set triggers keep their classes; each tracks the previous value. +_TRIGGER_AUTOMATIONS = ( + (CONF_ON_TURN_ON, []), + (CONF_ON_TURN_OFF, []), + (CONF_ON_DIRECTION_SET, [(FanDirection, "x")]), + (CONF_ON_OSCILLATING_SET, [(cg.bool_, "x")]), + (CONF_ON_SPEED_SET, [(cg.int_, "x")]), + (CONF_ON_PRESET_SET, [(cg.StringRef, "x")]), +) + + @setup_entity("fan") async def setup_fan_core_(var, config): cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) @@ -266,26 +270,10 @@ async def setup_fan_core_(var, config): await web_server.add_entity_config(var, web_server_config) for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(Fan.operator("ptr"), "x")], conf) - for conf in config.get(CONF_ON_TURN_ON, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_TURN_OFF, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_DIRECTION_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(FanDirection, "x")], conf) - for conf in config.get(CONF_ON_OSCILLATING_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.bool_, "x")], conf) - for conf in config.get(CONF_ON_SPEED_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.int_, "x")], conf) - for conf in config.get(CONF_ON_PRESET_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.StringRef, "x")], conf) + await automation.build_parent_callback_automation( + var, "add_on_state_callback", (Fan.operator("ptr"), "x"), conf + ) + await automation.build_trigger_automations(var, config, _TRIGGER_AUTOMATIONS) async def register_fan(var, config): diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 4c587904b7..e5ce09172b 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -50,16 +50,6 @@ template class CycleSpeedAction final : public Action { Fan *state_; }; -class FanStateTrigger final : public Trigger { - public: - FanStateTrigger(Fan *state) : fan_(state) { - state->add_on_state_callback([this]() { this->trigger(this->fan_); }); - } - - protected: - Fan *fan_; -}; - class FanTurnOnTrigger final : public Trigger<> { public: FanTurnOnTrigger(Fan *state) : fan_(state) { diff --git a/tests/components/fan/common-triggers.yaml b/tests/components/fan/common-triggers.yaml new file mode 100644 index 0000000000..d64f7b5e82 --- /dev/null +++ b/tests/components/fan/common-triggers.yaml @@ -0,0 +1,35 @@ +# on_state with the fan as trigger argument, plus the edge and set triggers. +fan: + - platform: template + name: Fan Triggers + id: fan_triggers + preset_modes: + - Eco + - Turbo + has_oscillating: true + has_direction: true + speed_count: 3 + on_state: + - logger.log: + format: "Fan is %s" + args: ['x->state ? "on" : "off"'] + on_turn_on: + - logger.log: Fan turned on + on_turn_off: + - logger.log: Fan turned off + on_direction_set: + - logger.log: + format: "Direction %d" + args: ["(int) x"] + on_oscillating_set: + - logger.log: + format: "Oscillating %d" + args: ["x"] + on_speed_set: + - logger.log: + format: "Speed %d" + args: ["x"] + on_preset_set: + - logger.log: + format: "Preset %s" + args: ["x.c_str()"] diff --git a/tests/components/fan/test-triggers.esp8266-ard.yaml b/tests/components/fan/test-triggers.esp8266-ard.yaml new file mode 100644 index 0000000000..0cfba2d491 --- /dev/null +++ b/tests/components/fan/test-triggers.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + fan: !include common.yaml + triggers: !include common-triggers.yaml From fa4bc28d3e442363e016136d070d66760ec2c92e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:54:44 -1000 Subject: [PATCH 141/268] [text] Build the on_value trigger with build_callback_automation (#19613) --- esphome/components/text/__init__.py | 17 ++++------------- esphome/components/text/automation.h | 16 ---------------- tests/components/text/common-triggers.yaml | 11 +++++++++++ .../text/test-triggers.esp8266-ard.yaml | 3 +++ 4 files changed, 18 insertions(+), 29 deletions(-) delete mode 100644 esphome/components/text/automation.h create mode 100644 tests/components/text/common-triggers.yaml create mode 100644 tests/components/text/test-triggers.esp8266-ard.yaml diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index fbbc09bd2d..243185b674 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_MODE, CONF_MQTT_ID, CONF_ON_VALUE, - CONF_TRIGGER_ID, CONF_VALUE, CONF_WEB_SERVER, ) @@ -30,11 +29,6 @@ text_ns = cg.esphome_ns.namespace("text") Text = text_ns.class_("Text", cg.EntityBase) TextPtr = Text.operator("ptr") -# Triggers -TextStateTrigger = text_ns.class_( - "TextStateTrigger", automation.Trigger.template(cg.std_string) -) - # Conditions TextMode = text_ns.enum("TextMode") @@ -50,11 +44,7 @@ _TEXT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextComponent), cv.GenerateID(): cv.declare_id(Text), - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TextStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation(), cv.Required(CONF_MODE): cv.enum(TEXT_MODES, upper=True), } ) @@ -104,8 +94,9 @@ async def setup_text_core_( cg.add(var.traits.set_mode(config[CONF_MODE])) for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(cg.std_string, "x")], conf + ) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h deleted file mode 100644 index bd6fb3cd23..0000000000 --- a/esphome/components/text/automation.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "text.h" - -namespace esphome::text { - -class TextStateTrigger final : public Trigger { - public: - explicit TextStateTrigger(Text *parent) { - parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -} // namespace esphome::text diff --git a/tests/components/text/common-triggers.yaml b/tests/components/text/common-triggers.yaml new file mode 100644 index 0000000000..585cb30c2d --- /dev/null +++ b/tests/components/text/common-triggers.yaml @@ -0,0 +1,11 @@ +# on_value with the new text as trigger argument. +text: + - platform: template + name: Text Triggers + id: text_triggers + optimistic: true + mode: text + on_value: + - logger.log: + format: "Text is %s" + args: ["x.c_str()"] diff --git a/tests/components/text/test-triggers.esp8266-ard.yaml b/tests/components/text/test-triggers.esp8266-ard.yaml new file mode 100644 index 0000000000..aecb27e602 --- /dev/null +++ b/tests/components/text/test-triggers.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + text: !include common.yaml + triggers: !include common-triggers.yaml From c6ed2069a566ea8f92b94828d0e2635718b342ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:55:51 -1000 Subject: [PATCH 142/268] [json][web_server] Link strings that outlive the document instead of copying them (#19380) --- esphome/components/json/json_util.h | 12 ++ .../components/light/light_json_schema.cpp | 4 +- esphome/components/web_server/web_server.cpp | 108 +++++++++++------- .../web_server_idf/web_server_idf.cpp | 2 +- esphome/core/string_ref.h | 6 + 5 files changed, 87 insertions(+), 45 deletions(-) diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index be12d0aa38..9b4cc53a2e 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -8,6 +8,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #define ARDUINOJSON_ENABLE_STD_STRING 1 // NOLINT @@ -17,6 +18,17 @@ namespace esphome::json { +// Link a string into the document instead of copying it. Only for a literal, a codegen table +// entry or a name set once during setup; never a stack buffer or an entity state. +inline JsonString linked(const char *s) { return JsonString(s, true); } +inline JsonString linked(const char *s, size_t len) { return JsonString(s, len, true); } +inline JsonString linked(const std::string &s) { return JsonString(s.c_str(), s.size(), true); } +JsonString linked(std::string &&) = delete; // a temporary dies before the document is serialized +#ifdef USE_ESP8266 +// A PROGMEM string is copied out of flash +inline ProgmemStr linked(ProgmemStr s) { return s; } +#endif + /// Buffer for JSON serialization that uses stack allocation for small payloads. /// Template parameter STACK_SIZE specifies the stack buffer size (default 512 bytes). /// Supports move semantics for efficient return-by-value. diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 98df98ce5d..40e9c0b1aa 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -27,7 +27,7 @@ static ProgmemStr get_color_mode_json_str(ColorMode mode) { void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson if (state.supports_effects()) { - root[ESPHOME_F("effect")] = state.get_effect_name().c_str(); + root[ESPHOME_F("effect")] = json::linked(state.get_effect_name()); root[ESPHOME_F("effect_index")] = state.get_current_effect_index(); root[ESPHOME_F("effect_count")] = state.get_effect_count(); } @@ -37,7 +37,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { const auto color_mode = values.get_color_mode(); const auto *mode_str = get_color_mode_json_str(color_mode); if (mode_str != nullptr) { - root[ESPHOME_F("color_mode")] = mode_str; + root[ESPHOME_F("color_mode")] = json::linked(mode_str); } if (color_mode & ColorCapability::ON_OFF) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 323857c37e..5fa3ea054b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -57,8 +57,25 @@ namespace esphome::web_server { ESPHOME_LOG_TAG(TAG, "web_server"); +using json::linked; + +#ifdef USE_STORE_LOG_STR_IN_FLASH // 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); } +#else +// Without the define a LogString is in RAM and can be linked +[[maybe_unused]] static JsonString json_state_str(const LogString *s) { return linked(LOG_STR_ARG(s)); } +#endif + +// get_icon_to() and get_device_class_to() return a table pointer, except on ESP8266 where they +// copy out of PROGMEM into the caller's buffer +[[maybe_unused]] static JsonString json_table_str(const char *s) { +#ifdef USE_ESP8266 + return JsonString(s); +#else + return linked(s); +#endif +} // One action of a POST /{domain}/{entity}/{action} request, kept in PROGMEM with the name inline so the // table needs no RAM on ESP8266. @@ -348,7 +365,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root[ESPHOME_F("name")] = group.second.name; + root[ESPHOME_F("name")] = linked(group.second.name); root[ESPHOME_F("sorting_weight")] = group.second.weight; auto group_msg = builder.serialize(); @@ -380,7 +397,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); - root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); + root[ESPHOME_F("title")] = linked(App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name()); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); root[ESPHOME_F("comment")] = static_cast(comment_buffer); @@ -616,17 +633,16 @@ static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, Js root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { - root[ESPHOME_F("domain")] = prefix; - // Use .c_str() to avoid instantiating set template (saves ~24B) - root[ESPHOME_F("name")] = name.c_str(); + root[ESPHOME_F("domain")] = linked(prefix, prefix_len); + root[ESPHOME_F("name")] = linked(name); #ifdef USE_DEVICES if (device_name) { - root[ESPHOME_F("device")] = device_name; + root[ESPHOME_F("device")] = linked(device_name, device_len); } #endif #ifdef USE_ENTITY_ICON char icon_buf[MAX_ICON_LENGTH]; - root[ESPHOME_F("icon")] = obj->get_icon_to(icon_buf); + root[ESPHOME_F("icon")] = json_table_str(obj->get_icon_to(icon_buf)); #endif root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); @@ -695,7 +711,7 @@ void WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_ if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); if (!uom_ref.empty()) - root[ESPHOME_F("uom")] = uom_ref.c_str(); + root[ESPHOME_F("uom")] = linked(uom_ref); } } #endif @@ -734,7 +750,9 @@ void WebServer::text_sensor_json_(text_sensor::TextSensor *obj, const std::strin json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "text_sensor", value.c_str(), value.c_str(), start_config); + // Not linked: the main loop can rewrite the state before the document is serialized + const JsonString state(value.c_str(), value.size()); + set_json_icon_state_value(root, obj, "text_sensor", state, state, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1041,9 +1059,9 @@ void WebServer::light_json_(light::LightState *obj, JsonDetail start_config, jso light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("effects")].to(); - opt.add("None"); + opt.add(linked("None")); for (auto const &option : obj->get_effects()) { - opt.add(option->get_name()); + opt.add(linked(option->get_name())); } this->add_sorting_info_(root, obj); } @@ -1109,7 +1127,7 @@ void WebServer::cover_all_json_generator(WebServer *web_server, void *source, js void WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, + set_json_icon_state_value(root, obj, "cover", linked(obj->is_fully_closed() ? "CLOSED" : "OPEN"), obj->position, start_config); root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation)); @@ -1173,9 +1191,14 @@ void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_ // Need two buffers: one for value, one for state with UOM char val_buf[VALUE_ACCURACY_MAX_LEN]; char state_buf[VALUE_ACCURACY_MAX_LEN]; - const char *val_str = std::isnan(value) ? "\"NaN\"" : (value_accuracy_to_buf(val_buf, value, accuracy), val_buf); - const char *state_str = - std::isnan(value) ? "NA" : (value_accuracy_with_uom_to_buf(state_buf, value, accuracy, uom_ref), state_buf); + JsonString val_str = linked("\"NaN\""); + JsonString state_str = linked("NA"); + if (!std::isnan(value)) { + value_accuracy_to_buf(val_buf, value, accuracy); + value_accuracy_with_uom_to_buf(state_buf, value, accuracy, uom_ref); + val_str = JsonString(val_buf); + state_str = JsonString(state_buf); + } set_json_icon_state_value(root, obj, "number", state_str, val_str, start_config); if (start_config == DETAIL_ALL) { // ArduinoJson copies the string immediately, so we can reuse val_buf @@ -1184,7 +1207,7 @@ void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_ root[ESPHOME_F("step")] = (value_accuracy_to_buf(val_buf, obj->traits.get_step(), accuracy), val_buf); root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); if (!uom_ref.empty()) - root[ESPHOME_F("uom")] = uom_ref.c_str(); + root[ESPHOME_F("uom")] = linked(uom_ref); this->add_sorting_info_(root, obj); } } @@ -1412,14 +1435,16 @@ void WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail json::JsonBuilder &builder) { JsonObject root = builder.root(); + // Not linked: the main loop can rewrite the state before the document is serialized + const JsonString copied_value(value.c_str(), value.size()); // A password entity shows the mask and prefills the input with nothing, so the secret never // reaches the JSON and the mask cannot be written back as the value const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD; - set_json_icon_state_value(root, obj, "text", password ? "********" : value.c_str(), password ? "" : value.c_str(), - start_config); + set_json_icon_state_value(root, obj, "text", password ? linked("********") : copied_value, + password ? linked("") : copied_value, start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); - root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); + root[ESPHOME_F("pattern")] = linked(obj->traits.get_pattern_c_str()); if (start_config == DETAIL_ALL) { root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); @@ -1475,12 +1500,12 @@ void WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail st json::JsonBuilder &builder) { JsonObject root = builder.root(); - // value points to null-terminated string literals from codegen (via current_option()) - set_json_icon_state_value(root, obj, "select", value.c_str(), value.c_str(), start_config); + const JsonString state = linked(value); + set_json_icon_state_value(root, obj, "select", state, state, start_config); if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("option")].to(); for (auto &option : obj->traits.get_options()) { - opt.add(option); + opt.add(linked(option)); } this->add_sorting_info_(root, obj); } @@ -1574,7 +1599,7 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js if (!traits.get_supported_custom_fan_modes().empty()) { JsonArray opt = root[ESPHOME_F("custom_fan_modes")].to(); for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) - opt.add(custom_fan_mode); + opt.add(linked(custom_fan_mode)); } if (traits.get_supports_swing_modes()) { JsonArray opt = root[ESPHOME_F("swing_modes")].to(); @@ -1589,7 +1614,7 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) - opt.add(custom_preset); + opt.add(linked(custom_preset)); } root[ESPHOME_F("max_temp")] = (value_accuracy_to_buf(temp_buf, traits.get_visual_max_temperature(), target_accuracy), temp_buf); @@ -1611,13 +1636,13 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { - root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); + root[ESPHOME_F("custom_fan_mode")] = linked(obj->get_custom_fan_mode()); } if (traits.get_supports_presets() && obj->preset.has_value()) { root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); + root[ESPHOME_F("custom_preset")] = linked(obj->get_custom_preset()); } if (traits.get_supports_swing_modes()) { root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode)); @@ -1788,7 +1813,7 @@ void WebServer::valve_all_json_generator(WebServer *web_server, void *source, js void WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, + set_json_icon_state_value(root, obj, "valve", linked(obj->is_fully_closed() ? "CLOSED" : "OPEN"), obj->position, start_config); root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation)); @@ -1936,9 +1961,8 @@ void WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail st JsonObject root = builder.root(); const auto mode = obj->get_mode(); - ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode)); - - set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); + set_json_icon_state_value(root, obj, "water_heater", json_state_str(water_heater::water_heater_mode_to_string(mode)), + mode, start_config); auto traits = obj->get_traits(); @@ -2209,15 +2233,15 @@ void WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail set_json_id(root, obj, "event", start_config); if (!event_type.empty()) { - root[ESPHOME_F("event_type")] = event_type; + root[ESPHOME_F("event_type")] = linked(event_type); } if (start_config == DETAIL_ALL) { JsonArray event_types = root[ESPHOME_F("event_types")].to(); for (const char *event_type : obj->get_event_types()) { - event_types.add(event_type); + event_types.add(linked(event_type)); } char dc_buf[MAX_DEVICE_CLASS_LENGTH]; - root[ESPHOME_F("device_class")] = obj->get_device_class_to(dc_buf); + root[ESPHOME_F("device_class")] = json_table_str(obj->get_device_class_to(dc_buf)); this->add_sorting_info_(root, obj); } } @@ -2265,17 +2289,17 @@ void WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config, // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson JsonObject root = builder.root(); + // Not linked: the main loop can rewrite update_info before the document is serialized + const auto &info = obj->update_info; set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version.c_str(), start_config); + info.latest_version.c_str(), start_config); if (start_config == DETAIL_ALL) { - root[ESPHOME_F("current_version")] = obj->update_info.current_version; - root[ESPHOME_F("title")] = obj->update_info.title; - // Truncate long changelogs — full text available via release_url + root[ESPHOME_F("current_version")] = info.current_version; + root[ESPHOME_F("title")] = info.title; + // Truncate long changelogs, the full text is available via release_url constexpr size_t max_summary_len = 256; - root[ESPHOME_F("summary")] = obj->update_info.summary.size() <= max_summary_len - ? obj->update_info.summary - : obj->update_info.summary.substr(0, max_summary_len); - root[ESPHOME_F("release_url")] = obj->update_info.release_url; + root[ESPHOME_F("summary")] = JsonString(info.summary.c_str(), std::min(info.summary.size(), max_summary_len)); + root[ESPHOME_F("release_url")] = info.release_url; this->add_sorting_info_(root, obj); } @@ -2610,7 +2634,7 @@ void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { if (this->sorting_entitys_.contains(entity)) { root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; if (this->sorting_groups_.contains(this->sorting_entitys_[entity].group_id)) { - root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; + root[ESPHOME_F("sorting_group")] = linked(this->sorting_groups_[this->sorting_entitys_[entity].group_id].name); } } #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9a6e419e43..953351f259 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -850,7 +850,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root["name"] = group.second.name; + root["name"] = json::linked(group.second.name); root["sorting_weight"] = group.second.weight; message = builder.serialize(); diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 4c0ce28a5a..1427541229 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -272,6 +272,7 @@ inline double stod(const StringRef &str, size_t *pos = nullptr) { // NOLINTEND(readability-identifier-naming,google-runtime-int,readability-non-const-parameter) #ifdef USE_JSON +// A StringRef may view a scratch buffer (mqtt topics do), so the document copies it // NOLINTNEXTLINE(readability-identifier-naming) inline void convertToJson(const StringRef &src, JsonVariant dst) { // Bounded by the view length; a null, empty view becomes "" rather than JSON null @@ -283,6 +284,11 @@ inline void convertToJson(const StringRef &src, JsonVariant dst) { // constructor and asks for empty() instead dst.set(JsonString(src.c_str(), src.size(), /* isStatic= */ false)); } + +namespace json { +// For a StringRef that outlives the document, see json_util.h. A null, empty view links "" too +inline JsonString linked(const StringRef &s) { return JsonString(s.empty() ? "" : s.c_str(), s.size(), true); } +} // namespace json #endif // USE_JSON } // namespace esphome From 650a02beb0a3018c7635a2cb04bdb7356e9e0950 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 16:13:15 -1000 Subject: [PATCH 143/268] [remote_transmitter] Compile the transmit actions the test config left out (#20550) --- .../remote_transmitter/common-buttons.yaml | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index f168a825d3..261f78b262 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -332,3 +332,80 @@ button: code: 0xd19ef0a9 suffix: 0x00 suffix_bits: 8 + - platform: template + name: KeeLoq lambda with repeat + on_press: + remote_transmitter.transmit_keeloq: + address: !lambda return 0x57ffe7b; + command: 0x02 + code: 0xd19ef0a9 + repeat: + times: 3 + wait_time: 10ms + - platform: template + name: Toto with repeat + on_press: + remote_transmitter.transmit_toto: + command: 0xEC + rc_code_1: 0x0D + rc_code_2: 0x0D + repeat: + times: 2 + wait_time: 20ms + - platform: template + name: ByronSX + on_press: + remote_transmitter.transmit_byronsx: + address: 0x12 + command: 0x05 + - platform: template + name: CanalSat + on_press: + remote_transmitter.transmit_canalsat: + device: 0x25 + address: 0x01 + command: 0x1C + - platform: template + name: CanalSatLD + on_press: + remote_transmitter.transmit_canalsatld: + device: 0x25 + command: !lambda return 0x1C; + - platform: template + name: Coolix + on_press: + remote_transmitter.transmit_coolix: + first: 0xB2BF40 + second: 0xB2BF40 + - platform: template + name: Dish + on_press: + remote_transmitter.transmit_dish: + address: 1 + command: 16 + - platform: template + name: Drayton + on_press: + remote_transmitter.transmit_drayton: + address: 0x1234 + channel: 0x01 + command: 0x05 + - platform: template + name: Gobox + on_press: + remote_transmitter.transmit_gobox: + code: 1 + - platform: template + name: Nexa + on_press: + remote_transmitter.transmit_nexa: + device: 0x38DDB5A + group: 0 + state: 1 + channel: 15 + level: 0 + - platform: template + name: Roomba + on_press: + remote_transmitter.transmit_roomba: + data: 0x88 From 40606a6c0866bcbfa698f7dff107d4c942ecd38f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 16:24:36 -1000 Subject: [PATCH 144/268] [core] Do not search the same heap twice in RAMAllocator single-region requests (#19381) --- esphome/core/helpers.h | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c54d1bc16f..50be278bb3 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -2214,7 +2214,7 @@ template class RAMAllocator { T *ptr = nullptr; #ifdef USE_ESP32 const auto caps = this->get_caps_(); - ptr = static_cast(heap_caps_malloc_prefer(size, 2, caps[0], caps[1])); + ptr = static_cast(heap_caps_malloc_prefer(size, caps.num, caps.primary, caps.fallback)); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(malloc(size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2229,7 +2229,7 @@ template class RAMAllocator { T *ptr = nullptr; #ifdef USE_ESP32 const auto caps = this->get_caps_(); - ptr = static_cast(heap_caps_realloc_prefer(p, size, 2, caps[0], caps[1])); + ptr = static_cast(heap_caps_realloc_prefer(p, size, caps.num, caps.primary, caps.fallback)); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(realloc(p, size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2301,12 +2301,21 @@ template class RAMAllocator { private: #ifdef USE_ESP32 - /// Returns {primary_caps, fallback_caps} for heap_caps_*_prefer based on the configured flags. + struct Caps { + uint32_t primary; + uint32_t fallback; + uint8_t num; // distinct capability sets to hand to heap_caps_*_prefer + constexpr Caps(uint32_t primary_caps, uint32_t fallback_caps) + : primary(primary_caps), fallback(fallback_caps), num(primary_caps == fallback_caps ? 1 : 2) {} + }; + + /// Returns the capability sets for heap_caps_*_prefer based on the configured flags. /// PREFER_INTERNAL implies both regions are enabled (enforced by the constructor), so when it is set /// the primary is internal and the fallback is external. Otherwise the primary is whichever region - /// is enabled (external preferred when both are enabled), and the fallback is the other region (or - /// the same region when only one is enabled, making the second attempt a no-op). - std::array get_caps_() const { + /// is enabled (external preferred when both are enabled), and the fallback is the other region (or the + /// same region when only one is enabled). With a single region enabled num is 1, so the duplicate + /// fallback is never searched. + constexpr Caps get_caps_() const { constexpr uint32_t external_caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; constexpr uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; if (this->flags_ & PREFER_INTERNAL) { From 847be14d1eb2979c4828ea0c0ff2e625c08711f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 16:27:41 -1000 Subject: [PATCH 145/268] [socket] Add lwip raw UDP socket implementation (#14677) Co-authored-by: Claude Opus 4.6 (1M context) --- esphome/components/socket/__init__.py | 2 + esphome/components/socket/bsd_sockets_impl.h | 3 + esphome/components/socket/headers.h | 10 + .../socket/lwip_raw_common_impl.cpp | 116 ++++++ .../components/socket/lwip_raw_common_impl.h | 53 +++ .../components/socket/lwip_raw_tcp_impl.cpp | 136 +------ esphome/components/socket/lwip_raw_tcp_impl.h | 5 +- .../components/socket/lwip_raw_udp_impl.cpp | 333 ++++++++++++++++++ esphome/components/socket/lwip_raw_udp_impl.h | 113 ++++++ esphome/components/socket/lwip_sockets_impl.h | 3 + esphome/components/socket/socket.cpp | 20 +- esphome/components/socket/socket.h | 57 ++- 12 files changed, 705 insertions(+), 146 deletions(-) create mode 100644 esphome/components/socket/lwip_raw_common_impl.cpp create mode 100644 esphome/components/socket/lwip_raw_common_impl.h create mode 100644 esphome/components/socket/lwip_raw_udp_impl.cpp create mode 100644 esphome/components/socket/lwip_raw_udp_impl.h diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index cc5f672ad5..effa7d864f 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -269,7 +269,9 @@ async def to_code(config: ConfigType) -> None: # consumers that called the matching require_ function. FILTER_SOURCE_FILES = filter_source_files_from_defines( { + "lwip_raw_common_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "lwip_raw_tcp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", + "lwip_raw_udp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "bsd_sockets_impl.cpp": "USE_SOCKET_IMPL_BSD_SOCKETS", "lwip_sockets_impl.cpp": "USE_SOCKET_IMPL_LWIP_SOCKETS", "ipv4_resolve.cpp": "USE_SOCKET_IPV4_RESOLVE", diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 1b5ea9ebcd..e2b5de6f10 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -144,6 +144,9 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } + /// UDP rx drop counter parity with LWIPRawUDPImpl; drops are not counted here. + uint16_t get_rx_dropped() const { return 0; } + protected: // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This // replaces a separate closed_ flag: close() sets fd_ = -1 after ::close(), and the diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index f4597f2522..1d50555cd1 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -20,6 +20,16 @@ #define IPPROTO_IP 0 #define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 + +#define IP_ADD_MEMBERSHIP 3 +#define IP_DROP_MEMBERSHIP 4 + +// NOLINTNEXTLINE(readability-identifier-naming) +struct ip_mreq { + struct in_addr imr_multiaddr; + struct in_addr imr_interface; +}; #if LWIP_IPV6 #define AF_INET6 10 diff --git a/esphome/components/socket/lwip_raw_common_impl.cpp b/esphome/components/socket/lwip_raw_common_impl.cpp new file mode 100644 index 0000000000..4ec0a37355 --- /dev/null +++ b/esphome/components/socket/lwip_raw_common_impl.cpp @@ -0,0 +1,116 @@ +#include "lwip_raw_common_impl.h" +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +namespace esphome::socket { + +int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, + socklen_t *addrlen) { + if (family == AF_INET) { + if (*addrlen < sizeof(struct sockaddr_in)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin_family = AF_INET; + *addrlen = addr->sin_len = sizeof(struct sockaddr_in); + addr->sin_port = htons(port_host); + inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); + return 0; + } +#if LWIP_IPV6 + if (family == AF_INET6) { + if (*addrlen < sizeof(struct sockaddr_in6)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin6_family = AF_INET6; + *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); + addr->sin6_port = htons(port_host); + // AF_INET6 sockets may receive IPv4 packets; convert to IPv4-mapped IPv6. + if (IP_IS_V4(ip)) { + ip_addr_t mapped; + ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); + } else { + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); + } + return 0; + } +#endif + errno = EAFNOSUPPORT; + return -1; +} + +bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { + // headers.h defines sockaddr and sockaddr_in with the same size, so this covers AF_INET + if (addrlen < sizeof(struct sockaddr)) + return false; + // Zero the whole struct — the IPv6 zone byte would otherwise be stack garbage + memset(ip, 0, sizeof(*ip)); + if (addr->sa_family == AF_INET) { + auto *addr4 = reinterpret_cast(addr); + *port = ntohs(addr4->sin_port); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V4); + ip_2_ip4(ip)->addr = addr4->sin_addr.s_addr; + return true; + } +#if LWIP_IPV6 + if (addr->sa_family == AF_INET6) { + if (addrlen < sizeof(sockaddr_in6)) + return false; + auto *addr6 = reinterpret_cast(addr); + *port = ntohs(addr6->sin6_port); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V6); + memcpy(&ip_2_ip6(ip)->addr, &addr6->sin6_addr.un.u8_addr, 16); + // Unmap ::ffff:a.b.c.d so replies to recvfrom addresses route as IPv4 + if (ip6_addr_isipv4mappedipv6(ip_2_ip6(ip))) { + unmap_ipv4_mapped_ipv6(ip_2_ip4(ip), ip_2_ip6(ip)); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V4); + } + return true; + } +#endif + return false; +} + +bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, + uint16_t *port) { + if (!sockaddr_to_lwip(addr, addrlen, ip, port)) + return false; +#if LWIP_IPV6 + // Only promote wildcard binds — a specific address must keep filtering + if (family == AF_INET6 && ip_addr_isany_val(*ip)) + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); +#endif + return true; +} + +int lwip_err_to_errno(err_t err) { + switch (err) { + case ERR_MEM: + return ENOMEM; + case ERR_BUF: + return EAGAIN; // transient, e.g. no free local port + case ERR_RTE: + return EHOSTUNREACH; // no route, e.g. no address yet + case ERR_VAL: + case ERR_ARG: + return EINVAL; + case ERR_USE: + return EADDRINUSE; + case ERR_ISCONN: + return EISCONN; + default: + return EIO; + } +} + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_common_impl.h b/esphome/components/socket/lwip_raw_common_impl.h new file mode 100644 index 0000000000..49e3a23f23 --- /dev/null +++ b/esphome/components/socket/lwip_raw_common_impl.h @@ -0,0 +1,53 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include "headers.h" +#include "lwip/ip.h" + +namespace esphome::socket { + +// ---- LWIP thread safety ---- +// +// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. +// This means lwip callbacks (recv_fn, accept_fn, err_fn) run from a low-priority +// user IRQ context, not the main loop (see low_priority_irq_handler() in pico-sdk +// async_context_threadsafe_background.c). They can preempt main-loop code at any point. +// +// Without locking, this causes race conditions between recv_fn and read() on the +// shared rx_buf_ pbuf chain — recv_fn calls pbuf_cat() while read() is freeing +// nodes, leading to use-after-free and infinite-loop crashes. See esphome#10681. +// +// On ESP8266, lwip callbacks run from the SYS context which cooperates with user +// code (CONT context) — they never preempt each other, so no locking is needed. +// +// esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). +// On RP2040, it acquires cyw43_arch_lwip_begin/end (WiFi) or ethernet_arch_lwip_begin/end +// (Ethernet). On ESP8266, it's a no-op. +// +// Each .cpp file that needs locking defines its own LWIP_LOCK() macro: +// #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard +// This is a per-TU convenience macro, not defined here to avoid macro leaking. + +/// Convert lwip ip_addr_t + host-order port to sockaddr, based on the socket's address family. +/// TCP callers pass ntohs(pcb port) to preserve historical getpeername/getsockname output. +int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, + socklen_t *addrlen); + +/// Convert sockaddr to lwip ip_addr_t and host-order port. +/// For IPv6, sets type to IPADDR_TYPE_V6 — correct for sendto destinations. +/// Bind paths must use sockaddr_to_lwip_bind() instead. +bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port); + +/// sockaddr_to_lwip variant for bind: promotes AF_INET6 sockets to +/// IPADDR_TYPE_ANY so they accept both IPv4 and IPv6 (dual-stack). +bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, + uint16_t *port); + +/// errno for a failed lwip call +int lwip_err_to_errno(err_t err); + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 2b21291c30..77ff0f4735 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -10,6 +10,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/wake.h" #include "esphome/core/log.h" +#include "lwip_raw_common_impl.h" #ifdef USE_OTA_PLATFORM_ESPHOME extern "C" void esphome_wake_ota_component_any_context(); @@ -24,23 +25,9 @@ extern "C" void esphome_wake_ota_component_any_context(); namespace esphome::socket { -// ---- LWIP thread safety ---- -// -// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. -// This means lwip callbacks (recv_fn, accept_fn, err_fn) run from a low-priority -// user IRQ context, not the main loop (see low_priority_irq_handler() in pico-sdk -// async_context_threadsafe_background.c). They can preempt main-loop code at any point. -// -// Without locking, this causes race conditions between recv_fn and read() on the -// shared rx_buf_ pbuf chain — recv_fn calls pbuf_cat() while read() is freeing -// nodes, leading to use-after-free and infinite-loop crashes. See esphome#10681. -// -// On ESP8266, lwip callbacks run from the SYS context which cooperates with user -// code (CONT context) — they never preempt each other, so no locking is needed. -// -// esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). -// On RP2040, it acquires cyw43_arch_lwip_begin/end (WiFi) or ethernet_arch_lwip_begin/end -// (Ethernet). On ESP8266, it's a no-op. +// LWIP thread safety — see lwip_raw_common_impl.h for full explanation. +// esphome::LwIPLock is the platform-provided RAII guard. +// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT ESPHOME_LOG_TAG(TAG, "socket"); @@ -54,27 +41,6 @@ static inline void yield_to_sys() { optimistic_yield(ESP8266_YIELD_INTERVAL_US); static inline void yield_to_sys() {} #endif -// errno for a failed tcp_* call -static int lwip_err_to_errno(err_t err) { - switch (err) { - case ERR_MEM: - return ENOMEM; - case ERR_BUF: - return EAGAIN; // transient, e.g. no free local port - case ERR_RTE: - return EHOSTUNREACH; // no route, e.g. no address yet - case ERR_VAL: - case ERR_ARG: - return EINVAL; - case ERR_USE: - return EADDRINUSE; - case ERR_ISCONN: - return EISCONN; - default: - return EIO; - } -} - // set to 1 to enable verbose lwip logging #if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) @@ -125,33 +91,14 @@ LWIPRawCommon::~LWIPRawCommon() { } } -bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const { - if (name == nullptr) { - errno = EINVAL; - return false; - } -#if LWIP_IPV6 - if (this->family_ == AF_INET6) { - if (addrlen < sizeof(sockaddr_in6)) { - errno = EINVAL; - return false; - } - auto *addr6 = reinterpret_cast(name); - *port = ntohs(addr6->sin6_port); - inet6_addr_to_ip6addr(ip_2_ip6(ip), &addr6->sin6_addr); - // ANY lets bind() accept both families; connect() picks the concrete type - IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); +bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port, + bool for_bind) const { + if (name != nullptr && name->sa_family == this->family_ && + (for_bind ? sockaddr_to_lwip_bind(this->family_, name, addrlen, ip, port) + : sockaddr_to_lwip(name, addrlen, ip, port))) return true; - } -#endif - if (this->family_ != AF_INET || addrlen < sizeof(sockaddr_in)) { - errno = EINVAL; - return false; - } - auto *addr4 = reinterpret_cast(name); - *port = ntohs(addr4->sin_port); - ip_addr_set_ip4_u32(ip, addr4->sin_addr.s_addr); - return true; + errno = EINVAL; + return false; } int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { @@ -162,7 +109,7 @@ int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { } ip_addr_t ip; uint16_t port; - if (!this->sockaddr2ip_(name, addrlen, &ip, &port)) { + if (!this->sockaddr2ip_(name, addrlen, &ip, &port, true)) { return -1; } LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port); @@ -357,43 +304,8 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle } int LWIPRawCommon::ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { - if (this->family_ == AF_INET) { - if (*addrlen < sizeof(struct sockaddr_in)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in *addr = reinterpret_cast(name); - addr->sin_family = AF_INET; - *addrlen = addr->sin_len = sizeof(struct sockaddr_in); - addr->sin_port = port; - inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); - return 0; - } -#if LWIP_IPV6 - else if (this->family_ == AF_INET6) { - if (*addrlen < sizeof(struct sockaddr_in6)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in6 *addr = reinterpret_cast(name); - addr->sin6_family = AF_INET6; - *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); - addr->sin6_port = port; - - // AF_INET6 sockets are bound to IPv4 as well, so we may encounter IPv4 addresses that must be converted to IPv6. - if (IP_IS_V4(ip)) { - ip_addr_t mapped; - ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); - } else { - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); - } - return 0; - } -#endif - return -1; + // lwip pcb ports are host order; ntohs preserves historical byte-swapped sin_port output + return lwip_ip_to_sockaddr(this->family_, ip, ntohs(port), name, addrlen); } // ---- LWIPRawImpl methods ---- @@ -462,20 +374,10 @@ int LWIPRawImpl::connect(const struct sockaddr *addr, socklen_t addrlen) { } ip_addr_t ip; uint16_t port; - if (!this->sockaddr2ip_(addr, addrlen, &ip, &port)) { + // Concrete type for tcp_connect; a remembered IPv4 peer arrives v4-mapped and is unmapped + if (!this->sockaddr2ip_(addr, addrlen, &ip, &port, false)) { return -1; } -#if LWIP_IPV6 - // tcp_connect needs a concrete type; a remembered IPv4 peer arrives v4-mapped - if (IP_IS_ANY_TYPE_VAL(ip)) { - if (ip6_addr_isipv4mappedipv6(ip_2_ip6(&ip))) { - unmap_ipv4_mapped_ipv6(ip_2_ip4(&ip), ip_2_ip6(&ip)); - IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V4); - } else { - IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V6); - } - } -#endif LWIP_LOG("tcp_connect(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port); err_t err = tcp_connect(this->pcb_, &ip, port, LWIPRawImpl::s_connected_fn); if (err != ERR_OK) { @@ -965,11 +867,11 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { return ERR_OK; } -// ---- Factory functions ---- +// ---- TCP Factory functions ---- std::unique_ptr socket(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } @@ -989,7 +891,7 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol std::unique_ptr socket_listen(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index ed8057e289..69158cc8fe 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -50,8 +50,9 @@ class LWIPRawCommon { protected: int ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); - /// sockaddr of this socket's family to lwip address and port; false with errno on mismatch - bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const; + /// sockaddr of this socket's family to lwip address and port; false with errno on mismatch. + /// for_bind keeps an IPv6 wildcard dual stack, otherwise the type is concrete. + bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port, bool for_bind) const; // Member ordering optimized to minimize padding on 32-bit systems struct tcp_pcb *pcb_; diff --git a/esphome/components/socket/lwip_raw_udp_impl.cpp b/esphome/components/socket/lwip_raw_udp_impl.cpp new file mode 100644 index 0000000000..dceba802ae --- /dev/null +++ b/esphome/components/socket/lwip_raw_udp_impl.cpp @@ -0,0 +1,333 @@ +#include "socket.h" +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/wake.h" +#include "lwip_raw_common_impl.h" + +#include "lwip/igmp.h" +#include "lwip/pbuf.h" +#include "lwip/udp.h" + +namespace esphome::socket { + +// LWIP thread safety — see lwip_raw_common_impl.h for full explanation. +// esphome::LwIPLock is the platform-provided RAII guard. +// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. +#define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT + +// ---- LWIPRawUDPSendImpl (send-only) methods ---- + +LWIPRawUDPSendImpl::~LWIPRawUDPSendImpl() { + // Guard avoids acquiring the lwip lock when already closed + if (this->pcb_ != nullptr) + this->close(); +} + +int LWIPRawUDPSendImpl::bind(const struct sockaddr *name, socklen_t addrlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (name == nullptr) { + errno = EINVAL; + return -1; + } + ip_addr_t ip; + uint16_t port; + if (!sockaddr_to_lwip_bind(this->family_, name, addrlen, &ip, &port)) { + errno = EINVAL; + return -1; + } + err_t err = udp_bind(this->pcb_, &ip, port); + if (err != ERR_OK) { + errno = lwip_err_to_errno(err); + return -1; + } + return 0; +} + +int LWIPRawUDPSendImpl::close() { + LWIP_LOCK(); + return this->close_internal_locked_(); +} + +int LWIPRawUDPSendImpl::close_internal_locked_() { + // Caller must hold LWIP_LOCK + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + udp_remove(this->pcb_); + this->pcb_ = nullptr; + return 0; +} + +int LWIPRawUDPSendImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { + // UDP recv callback provides port in host byte order + return lwip_ip_to_sockaddr(this->family_, ip, port, name, addrlen); +} + +ssize_t LWIPRawUDPSendImpl::sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, + socklen_t addrlen) { + (void) flags; // Flags (MSG_DONTWAIT, etc.) are ignored; raw lwip is always non-blocking + if (buf == nullptr || dest_addr == nullptr) { + errno = EINVAL; + return -1; + } + + // pbuf_alloc takes u16_t length; reject oversized packets + if (len > UINT16_MAX) { + errno = EMSGSIZE; + return -1; + } + + ip_addr_t dst_ip; + uint16_t dst_port; + if (!sockaddr_to_lwip(dest_addr, addrlen, &dst_ip, &dst_port)) { + errno = EINVAL; + return -1; + } + + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + + // Allocate pbuf and copy data + struct pbuf *pb = pbuf_alloc(PBUF_TRANSPORT, (uint16_t) len, PBUF_RAM); + if (pb == nullptr) { + errno = ENOMEM; + return -1; + } + memcpy(pb->payload, buf, len); + + err_t err = udp_sendto(this->pcb_, pb, &dst_ip, dst_port); + pbuf_free(pb); + + if (err != ERR_OK) { + errno = lwip_err_to_errno(err); + return -1; + } + return (ssize_t) len; +} + +int LWIPRawUDPSendImpl::setsockopt(int level, int optname, const void *optval, socklen_t optlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + if (optval == nullptr || optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + // Effective only where lwip is built with SO_REUSE=1 (ESP8266 yes, RP2040 currently no) + if (*reinterpret_cast(optval)) { + ip_set_option(this->pcb_, SOF_REUSEADDR); + } else { + ip_reset_option(this->pcb_, SOF_REUSEADDR); + } + return 0; + } + if (level == SOL_SOCKET && optname == SO_BROADCAST) { + if (optval == nullptr || optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + int val = *reinterpret_cast(optval); + if (val) { + ip_set_option(this->pcb_, SOF_BROADCAST); + } else { + ip_reset_option(this->pcb_, SOF_BROADCAST); + } + return 0; + } + if (level == IPPROTO_IP && (optname == IP_ADD_MEMBERSHIP || optname == IP_DROP_MEMBERSHIP)) { + if (optval == nullptr || optlen < sizeof(struct ip_mreq)) { + errno = EINVAL; + return -1; + } + auto *mreq = reinterpret_cast(optval); + ip4_addr_t multiaddr{mreq->imr_multiaddr.s_addr}; + ip4_addr_t ifaddr{mreq->imr_interface.s_addr}; + err_t err = + optname == IP_ADD_MEMBERSHIP ? igmp_joingroup(&ifaddr, &multiaddr) : igmp_leavegroup(&ifaddr, &multiaddr); + if (err != ERR_OK) { + errno = EIO; + return -1; + } + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPSendImpl::getsockopt(int level, int optname, void *optval, socklen_t *optlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + if (optval == nullptr || optlen == nullptr || *optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + *reinterpret_cast(optval) = ip_get_option(this->pcb_, SOF_REUSEADDR) ? 1 : 0; + *optlen = sizeof(int); + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPSendImpl::setblocking(bool blocking) { + if (blocking) { + // blocking operation not supported on raw lwip + errno = EINVAL; + return -1; + } + return 0; +} + +// ---- LWIPRawUDPImpl methods ---- + +LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family, struct udp_pcb *pcb) : LWIPRawUDPSendImpl(family, pcb) { + // Registered here (not in bind) so unbound client sockets can receive replies + udp_recv(this->pcb_, LWIPRawUDPImpl::s_recv_fn, this); +} + +LWIPRawUDPImpl::~LWIPRawUDPImpl() { + // Flush rx queue and unregister callback before base destructor removes pcb + if (this->pcb_ != nullptr) + this->close(); +} + +int LWIPRawUDPImpl::close() { + LWIP_LOCK(); + // Unregister recv callback before removing pcb + if (this->pcb_ != nullptr) { + udp_recv(this->pcb_, nullptr, nullptr); + } + // Flush queued rx packets; slots within rx_count_ always hold a live pbuf + for (; this->rx_count_ > 0; this->rx_count_--) { + pbuf_free(this->rx_queue_[this->rx_read_idx_].pb); + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + } + // close_internal_locked_() returns EBADF if already closed, which is fine from destructor + return this->close_internal_locked_(); +} + +ssize_t LWIPRawUDPImpl::read(void *buf, size_t len) { return this->recvfrom(buf, len, nullptr, nullptr); } + +ssize_t LWIPRawUDPImpl::recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen) { + if (buf == nullptr && len > 0) { + errno = EINVAL; + return -1; + } + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (this->rx_count_ == 0) { + errno = EWOULDBLOCK; + return -1; + } + + auto &pkt = this->rx_queue_[this->rx_read_idx_]; + // On address conversion failure, still consume the packet — the failure is + // deterministic (family_ and *addrlen), so keeping it would wedge the queue + ssize_t ret = -1; + if (src_addr == nullptr || addrlen == nullptr || + this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen) == 0) { + ret = (ssize_t) std::min(len, (size_t) pkt.pb->tot_len); + pbuf_copy_partial(pkt.pb, buf, ret, 0); + } + pbuf_free(pkt.pb); + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + this->rx_count_--; + return ret; +} + +void LWIPRawUDPImpl::s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { + auto *self = reinterpret_cast(arg); + self->recv_fn_(p, addr, port); +} + +// LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). +// No heap allocation allowed — malloc is not IRQ-safe (see #14687). +// No LWIP_LOCK() needed — lwip core already holds the async_context lock. +void LWIPRawUDPImpl::recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port) { + if (p == nullptr) + return; + + // Check if queue is full + if (this->rx_count_ >= UDP_RX_QUEUE_SIZE) { + // Drop packet — queue full. Can't log from IRQ context, so count it + // (saturating) for consumers to surface via get_rx_dropped(). + if (this->rx_dropped_ != UINT16_MAX) + this->rx_dropped_++; + pbuf_free(p); + return; + } + + // Enqueue the packet + uint8_t write_idx = (this->rx_read_idx_ + this->rx_count_) & UDP_RX_MASK; + auto &slot = this->rx_queue_[write_idx]; + slot.pb = p; + slot.src_addr = *addr; + slot.src_port = port; + this->rx_count_++; + + esphome::wake_loop_any_context(); +} + +// ---- UDP Factory functions ---- + +static struct udp_pcb *new_udp_pcb(int domain) { +#if LWIP_IPV6 + return udp_new_ip_type(domain == AF_INET6 ? IPADDR_TYPE_ANY : IPADDR_TYPE_V4); +#else + return udp_new(); +#endif +} + +std::unique_ptr socket_udp_send(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + LWIP_LOCK(); + auto *pcb = new_udp_pcb(domain); + if (pcb == nullptr) { + errno = ENOMEM; + return nullptr; + } + return make_unique((sa_family_t) domain, pcb); +} + +std::unique_ptr socket_udp(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + LWIP_LOCK(); + auto *pcb = new_udp_pcb(domain); + if (pcb == nullptr) { + errno = ENOMEM; + return nullptr; + } + // Ctor registers the recv callback under the lock held here + return make_unique((sa_family_t) domain, pcb); +} + +#undef LWIP_LOCK + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_udp_impl.h b/esphome/components/socket/lwip_raw_udp_impl.h new file mode 100644 index 0000000000..268515106f --- /dev/null +++ b/esphome/components/socket/lwip_raw_udp_impl.h @@ -0,0 +1,113 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +#include "headers.h" +#include "lwip/ip.h" +#include "lwip/udp.h" + +namespace esphome::socket { + +/// Send-only UDP socket implementation for LWIP raw API. +/// Non-virtual, concrete type. Uses lwip/udp.h raw API. +/// No receive capability — use LWIPRawUDPImpl for sockets that need to receive. +class LWIPRawUDPSendImpl { + public: + /// The pcb is allocated by the factory (like the TCP impl); never null here. + LWIPRawUDPSendImpl(sa_family_t family, struct udp_pcb *pcb) : pcb_(pcb), family_(family) {} + ~LWIPRawUDPSendImpl(); + LWIPRawUDPSendImpl(const LWIPRawUDPSendImpl &) = delete; + LWIPRawUDPSendImpl &operator=(const LWIPRawUDPSendImpl &) = delete; + + int bind(const struct sockaddr *name, socklen_t addrlen); + int close(); + + /// Send a UDP packet to the specified destination. + ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); + + int setsockopt(int level, int optname, const void *optval, socklen_t optlen); + int getsockopt(int level, int optname, void *optval, socklen_t *optlen); + + int setblocking(bool blocking); + + bool ready() const { return false; } + int get_fd() const { return -1; } + + protected: + /// Convert lwip ip_addr_t and port to sockaddr. + int ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); + + /// Shared close logic — removes the udp pcb. Caller must hold LWIP_LOCK. + int close_internal_locked_(); + + struct udp_pcb *pcb_; + sa_family_t family_; +}; + +/// UDP socket with receive support for LWIP raw API. +/// Extends LWIPRawUDPSendImpl with a fixed-size ring buffer for incoming packets. +/// Inheritance is private (base dtor is non-virtual; converting to a base +/// pointer would leak queued pbufs on destruction). +class LWIPRawUDPImpl : private LWIPRawUDPSendImpl { + public: + /// Caller (the factory) must hold the lwip lock; registers the recv callback. + LWIPRawUDPImpl(sa_family_t family, struct udp_pcb *pcb); + ~LWIPRawUDPImpl(); + + using LWIPRawUDPSendImpl::bind; + using LWIPRawUDPSendImpl::get_fd; + using LWIPRawUDPSendImpl::getsockopt; + using LWIPRawUDPSendImpl::sendto; + using LWIPRawUDPSendImpl::setblocking; + using LWIPRawUDPSendImpl::setsockopt; + + /// Close the socket, flushing any queued rx packets first. + int close(); + + /// Read the next queued packet, discarding source address info. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. + ssize_t read(void *buf, size_t len); + /// Read the next queued packet and return the source address. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. + ssize_t recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen); + + /// Returns true if there are packets available to read. + /// Intentionally unlocked — same rationale as LWIPRawImpl::ready(). + bool ready() const { return this->rx_count_ > 0; } + + /// Number of packets dropped because the rx queue was full (saturating). + uint16_t get_rx_dropped() const { return this->rx_dropped_; } + + protected: + static void s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port); + void recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port); + + /// Ring buffer for received UDP packets. + /// Both producer (recv callback) and consumer (main loop) are serialized by the + /// lwip lock — the callback runs under lwip core lock, and consumer methods hold + /// LWIP_LOCK(). All 4 slots are usable (no wasted slot for full/empty distinction). + /// No heap allocation in the recv callback — packets are dropped if the queue is full. + static constexpr uint8_t UDP_RX_QUEUE_SIZE = 4; + static constexpr uint8_t UDP_RX_MASK = UDP_RX_QUEUE_SIZE - 1; + static_assert((UDP_RX_QUEUE_SIZE & UDP_RX_MASK) == 0, "UDP_RX_QUEUE_SIZE must be power of 2"); + // Fields are written by recv_fn_ before rx_count_ makes a slot visible + struct UDPRxPacket { + ip_addr_t src_addr; + struct pbuf *pb; + uint16_t src_port; + }; + std::array rx_queue_{}; + uint16_t rx_dropped_{0}; + uint8_t rx_read_idx_{0}; + uint8_t rx_count_{0}; +}; + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 7f3b706cd8..9cba164716 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -84,6 +84,9 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } + /// UDP rx drop counter parity with LWIPRawUDPImpl; drops are not counted here. + uint16_t get_rx_dropped() const { return 0; } + protected: // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This // replaces a separate closed_ flag: close() sets fd_ = -1 after lwip_close(), and the diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 89002cdd19..a91c455e0f 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -129,25 +129,7 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s return 0; } -std::unique_ptr socket_ip(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket(AF_INET6, type, protocol); -#else - return socket(AF_INET, type, protocol); -#endif /* USE_NETWORK_IPV6 */ -} - -#ifdef USE_SOCKET_IMPL_LWIP_TCP -// LWIP_TCP has separate Socket/ListenSocket types — needs out-of-line factory. -// BSD and LWIP_SOCKETS define this inline in socket.h. -std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket_listen_loop_monitored(AF_INET6, type, protocol); -#else - return socket_listen_loop_monitored(AF_INET, type, protocol); -#endif /* USE_NETWORK_IPV6 */ -} -#endif +std::unique_ptr socket_ip(int type, int protocol) { return socket(IP_DOMAIN, type, protocol); } #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) // Callback must be a temporary lambda diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index d1a70c5aa8..8f5860e26e 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -20,6 +20,7 @@ #include "lwip_sockets_impl.h" #elif defined(USE_SOCKET_IMPL_LWIP_TCP) #include "lwip_raw_tcp_impl.h" +#include "lwip_raw_udp_impl.h" #endif namespace esphome::socket { @@ -27,17 +28,32 @@ namespace esphome::socket { // Type aliases — only one implementation is active per build. // Socket is the concrete type for connected sockets. // ListenSocket is the concrete type for listening/server sockets. -// On BSD and LWIP_SOCKETS, both aliases resolve to the same type. +// UDPSocket is the concrete type for UDP sockets (send + receive). +// UDPSendSocket is the concrete type for send-only UDP sockets. +// On BSD and LWIP_SOCKETS, all aliases resolve to the same type. // On LWIP_TCP, they are different types (no virtual dispatch between them). #ifdef USE_SOCKET_IMPL_BSD_SOCKETS using Socket = BSDSocketImpl; using ListenSocket = BSDSocketImpl; +using UDPSendSocket = BSDSocketImpl; +using UDPSocket = BSDSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_SOCKETS) using Socket = LwIPSocketImpl; using ListenSocket = LwIPSocketImpl; +using UDPSendSocket = LwIPSocketImpl; +using UDPSocket = LwIPSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_TCP) using Socket = LWIPRawImpl; using ListenSocket = LWIPRawListenImpl; +using UDPSendSocket = LWIPRawUDPSendImpl; +using UDPSocket = LWIPRawUDPImpl; +#endif + +// Domain used by the socket_ip_* helpers: newest available IP domain. +#if USE_NETWORK_IPV6 +inline constexpr int IP_DOMAIN = AF_INET6; +#else +inline constexpr int IP_DOMAIN = AF_INET; #endif #ifdef USE_LWIP_FAST_SELECT @@ -105,6 +121,36 @@ std::unique_ptr socket_ip(int type, int protocol); /// File descriptors >= FD_SETSIZE will not be monitored and will log an error. std::unique_ptr socket_loop_monitored(int domain, int type, int protocol); +/// Create a send-only UDP socket (socket_udp_send), a UDP socket with receive +/// support (socket_udp), or a UDP socket monitored for data in the main loop +/// (socket_udp_loop_monitored). +#ifdef USE_SOCKET_IMPL_LWIP_TCP +std::unique_ptr socket_udp_send(int domain, int protocol); +std::unique_ptr socket_udp(int domain, int protocol); +// Wake is built into the recv callback, so monitoring needs nothing extra. +inline std::unique_ptr socket_udp_loop_monitored(int domain, int protocol) { + return socket_udp(domain, protocol); +} +#else +inline std::unique_ptr socket_udp_send(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +inline std::unique_ptr socket_udp(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +// Registers the socket with the Application's select() loop. +inline std::unique_ptr socket_udp_loop_monitored(int domain, int protocol) { + return socket_loop_monitored(domain, SOCK_DGRAM, protocol); +} +#endif + +/// socket_udp* variants using the newest available IP domain. +inline std::unique_ptr socket_ip_udp_send(int protocol) { return socket_udp_send(IP_DOMAIN, protocol); } +inline std::unique_ptr socket_ip_udp(int protocol) { return socket_udp(IP_DOMAIN, protocol); } +inline std::unique_ptr socket_ip_udp_loop_monitored(int protocol) { + return socket_udp_loop_monitored(IP_DOMAIN, protocol); +} + /// Create a listening socket of the given domain, type and protocol. /// Create a listening socket and monitor it for data in the main loop. /// Create a listening socket in the newest available IP domain and monitor it. @@ -112,7 +158,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol // LWIP_TCP has separate Socket/ListenSocket types — needs distinct factory functions. std::unique_ptr socket_listen(int domain, int type, int protocol); std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol); -std::unique_ptr socket_ip_loop_monitored(int type, int protocol); #else // BSD and LWIP_SOCKETS: Socket == ListenSocket, so listen variants just delegate. inline std::unique_ptr socket_listen(int domain, int type, int protocol) { @@ -121,14 +166,10 @@ inline std::unique_ptr socket_listen(int domain, int type, int pro inline std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol) { return socket_loop_monitored(domain, type, protocol); } +#endif inline std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket_loop_monitored(AF_INET6, type, protocol); -#else - return socket_loop_monitored(AF_INET, type, protocol); -#endif + return socket_listen_loop_monitored(IP_DOMAIN, type, protocol); } -#endif /// Set a sockaddr to the specified address and port for the IP version used by socket_ip(). /// @param addr Destination sockaddr structure From 6095d9d610c85f2606aeddc85ed93b8763610786 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 16:56:56 -1000 Subject: [PATCH 146/268] [improv_serial] Remove the duplicate timeout id left by the beta merge (#20562) --- esphome/components/improv_serial/improv_serial_component.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ac0b33b6ae..906781e17b 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -20,8 +20,6 @@ ESPHOME_LOG_TAG(TAG, "improv_serial"); static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0; -static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0; - void ImprovSerialComponent::setup() { global_improv_serial_component = this; #ifdef USE_IMPROV_SERIAL_UART From d19bb7f4be7589723a2bf9d451b5630d0b099b06 Mon Sep 17 00:00:00 2001 From: mick96 Date: Mon, 12 Oct 2026 05:00:16 +0200 Subject: [PATCH 147/268] [cse7761] Add current_input option for current transformer boards such as the Sonoff POW CT (#7836) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/cse7761/cse7761.cpp | 29 ++++++++++++++----- esphome/components/cse7761/cse7761.h | 10 +++++++ esphome/components/cse7761/sensor.py | 12 +++++++- .../cse7761/common-current-transformer.yaml | 11 +++++++ tests/components/cse7761/test.esp32-idf.yaml | 3 +- .../components/cse7761/test.esp8266-ard.yaml | 3 +- tests/components/cse7761/test.rp2040-ard.yaml | 3 +- ...alidate-current-transformer.esp32-idf.yaml | 7 +++++ 8 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 tests/components/cse7761/common-current-transformer.yaml create mode 100644 tests/components/cse7761/validate-current-transformer.esp32-idf.yaml diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index a610181222..4ba93af01f 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -18,6 +18,14 @@ static constexpr int CSE7761_UREF = 42563; // RmsUc static constexpr int CSE7761_IREF = 52241; // RmsIAC static constexpr int CSE7761_PREF = 44513; // PowerPAC +// Empirical Sonoff POW CT factor taken from Tasmota +static constexpr uint32_t CURRENT_TRANSFORMER_COEFFICIENT_SCALE = 5; + +// PGA 16x on both current channels +static constexpr uint16_t SYSCON_SHUNT = 0xFF04; +// All PGAs 1x, ADC2ON +static constexpr uint16_t SYSCON_CURRENT_TRANSFORMER = 0xFE00; + // System clock (3.579545MHz) / 8, divided by Ufreq gives the line frequency in Hz static constexpr float CSE7761_UFREQ_CLOCK = 3579545.0f / 8.0f; @@ -68,6 +76,8 @@ void CSE7761Component::dump_config() { if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } + ESP_LOGCONFIG(TAG, " Current input: %s", + LOG_STR_ARG(this->is_current_transformer_() ? LOG_STR("current transformer") : LOG_STR("shunt"))); LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); LOG_SENSOR(" ", "Current 1", this->current_sensor_1_); @@ -93,7 +103,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { buffer[1] = reg; uint32_t len = 2; if (data) { - if (data < 0xFF) { + if (data <= 0xFF) { buffer[2] = data & 0xFF; len = 3; } else { @@ -158,16 +168,17 @@ uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { } uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) { + const uint32_t scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1; uint32_t coeff = 0; switch (unit) { case RMS_UC: coeff = this->data_.coefficient[RMS_UC]; return coeff ? 0x400000 * 100 / coeff : 0; case RMS_IAC: - coeff = this->data_.coefficient[RMS_IAC]; + coeff = this->data_.coefficient[RMS_IAC] * scale; return coeff ? (0x800000 * 100 / coeff) * 10 : 0; // Stay within 32 bits case POWER_PAC: - coeff = this->data_.coefficient[POWER_PAC]; + coeff = this->data_.coefficient[POWER_PAC] * scale; return coeff ? 0x80000000 / coeff : 0; } return 0; @@ -195,9 +206,11 @@ bool CSE7761Component::chip_init_() { if (!this->read_(CSE7761_REG_HFCONST, 2, &hf_const)) { ESP_LOGW(TAG, "HFConst read failed, energy unavailable"); } + // The pulse counters follow the raw power, so they need the same front end factor as power + const float input_scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1.0f; 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->energy_[channel].wh_per_pulse = static_cast(scale) * CSE7761_ENERGY_WH_SCALE * input_scale; } } @@ -205,7 +218,8 @@ bool CSE7761Component::chip_init_() { uint8_t sys_status = this->read_(CSE7761_REG_SYSSTATUS, 1); if (sys_status & 0x10) { // Write enable to protected registers (WREN) - this->write_(CSE7761_REG_SYSCON | 0x80, 0xFF04); + this->write_(CSE7761_REG_SYSCON | 0x80, + this->is_current_transformer_() ? SYSCON_CURRENT_TRANSFORMER : SYSCON_SHUNT); this->write_(CSE7761_REG_EMUCON | 0x80, 0x1183); // WaveEN|ZxEN, needed for Ufreq this->write_(CSE7761_REG_EMUCON2 | 0x80, this->frequency_sensor_ != nullptr ? 0x0FE5 : 0x0FC1); @@ -226,14 +240,15 @@ void CSE7761Component::get_data_() { uint32_t value = this->read_(CSE7761_REG_RMSU, 3); this->data_.voltage_rms = (value >= 0x800000) ? 0 : value; + // No load threshold of 10mA with a shunt; the raw value stands for 5 times more with a current transformer value = this->read_(CSE7761_REG_RMSIA, 3); - this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA + this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; value = this->read_(CSE7761_REG_POWERPA, 4); // PowerPA is two's complement signed 32-bit per datasheet this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : static_cast(value); value = this->read_(CSE7761_REG_RMSIB, 3); - this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA + this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; value = this->read_(CSE7761_REG_POWERPB, 4); // PowerPB is two's complement signed 32-bit per datasheet this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : static_cast(value); diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index 557de0e342..fc4eac6477 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -21,9 +21,15 @@ struct CSE7761EnergyChannel { float wh_per_pulse{0.0f}; }; +enum class CSE7761CurrentInput : uint8_t { + CSE7761_CURRENT_INPUT_SHUNT, + CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER, +}; + /// This class implements support for the CSE7761 UART power sensor. class CSE7761Component final : public PollingComponent, public uart::UARTDevice { public: + explicit CSE7761Component(CSE7761CurrentInput current_input) : current_input_(current_input) {} 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) { @@ -51,7 +57,11 @@ class CSE7761Component final : public PollingComponent, public uart::UARTDevice sensor::Sensor *energy_sensors_[2]{nullptr, nullptr}; CSE7761DataStruct data_; CSE7761EnergyChannel energy_[2]; + const CSE7761CurrentInput current_input_; + bool is_current_transformer_() const { + return this->current_input_ == CSE7761CurrentInput::CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER; + } 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); diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 8ffbb44771..6f695307de 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -36,11 +36,21 @@ CONF_ACTIVE_POWER_2 = "active_power_2" CONF_ENERGY_1 = "energy_1" CONF_ENERGY_2 = "energy_2" CONF_POWER_FACTOR_1 = "power_factor_1" +CONF_CURRENT_INPUT = "current_input" + +CSE7761CurrentInput = cse7761_ns.enum("CSE7761CurrentInput", is_class=True) +CURRENT_INPUTS = { + "shunt": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_SHUNT, + "current_transformer": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER, +} CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(CSE7761Component), + cv.Optional(CONF_CURRENT_INPUT, default="shunt"): cv.enum( + CURRENT_INPUTS, lower=True + ), cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, @@ -112,7 +122,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) + var = cg.new_Pvariable(config[CONF_ID], config[CONF_CURRENT_INPUT]) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/tests/components/cse7761/common-current-transformer.yaml b/tests/components/cse7761/common-current-transformer.yaml new file mode 100644 index 0000000000..6c5854d8af --- /dev/null +++ b/tests/components/cse7761/common-current-transformer.yaml @@ -0,0 +1,11 @@ +sensor: + - platform: cse7761 + current_input: current_transformer + voltage: + name: CSE7761 CT Voltage + current_1: + name: CSE7761 CT Current 1 + active_power_1: + name: CSE7761 CT Active Power 1 + energy_1: + name: CSE7761 CT Energy 1 diff --git a/tests/components/cse7761/test.esp32-idf.yaml b/tests/components/cse7761/test.esp32-idf.yaml index b9ae061c25..c0c97c2bec 100644 --- a/tests/components/cse7761/test.esp32-idf.yaml +++ b/tests/components/cse7761/test.esp32-idf.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp32-idf.yaml - -<<: !include common.yaml + cse7761: !include common.yaml diff --git a/tests/components/cse7761/test.esp8266-ard.yaml b/tests/components/cse7761/test.esp8266-ard.yaml index 0d57039e1c..d1d376eebc 100644 --- a/tests/components/cse7761/test.esp8266-ard.yaml +++ b/tests/components/cse7761/test.esp8266-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp8266-ard.yaml - -<<: !include common.yaml + cse7761: !include common.yaml diff --git a/tests/components/cse7761/test.rp2040-ard.yaml b/tests/components/cse7761/test.rp2040-ard.yaml index 65e6252c51..3850be0070 100644 --- a/tests/components/cse7761/test.rp2040-ard.yaml +++ b/tests/components/cse7761/test.rp2040-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart_38400_even: !include ../../test_build_components/common/uart_38400_even/rp2040-ard.yaml - -<<: !include common.yaml + cse7761: !include common.yaml diff --git a/tests/components/cse7761/validate-current-transformer.esp32-idf.yaml b/tests/components/cse7761/validate-current-transformer.esp32-idf.yaml new file mode 100644 index 0000000000..9ad82dcf96 --- /dev/null +++ b/tests/components/cse7761/validate-current-transformer.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO12 + rx_pin: GPIO14 + +packages: + uart_38400_even: !include ../../test_build_components/common/uart_38400_even/esp32-idf.yaml + cse7761: !include common-current-transformer.yaml From 08a55c97b7e1c43a5eb5992d515391c8dc8ea4e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 17:03:59 -1000 Subject: [PATCH 148/268] [remote_base] Generate the simple transmit actions with no C++ class (#20549) --- esphome/automation.py | 20 +- esphome/components/remote_base/__init__.py | 569 ++++++++++-------- .../components/remote_base/beo4_protocol.h | 15 - .../remote_base/brennenstuhl_protocol.h | 11 - .../components/remote_base/byronsx_protocol.h | 13 - .../remote_base/canalsat_protocol.h | 30 - .../components/remote_base/coolix_protocol.h | 8 - .../components/remote_base/dish_protocol.h | 13 - .../components/remote_base/dooya_protocol.h | 17 - .../components/remote_base/drayton_protocol.h | 15 - .../components/remote_base/dyson_protocol.h | 13 - .../components/remote_base/gobox_protocol.h | 11 - .../remote_base/hob2hood_protocol.h | 8 - esphome/components/remote_base/jvc_protocol.h | 11 - .../components/remote_base/keeloq_protocol.h | 21 - esphome/components/remote_base/lg_protocol.h | 13 - .../remote_base/magiquest_protocol.h | 13 - esphome/components/remote_base/nec_protocol.h | 15 - .../components/remote_base/nexa_protocol.h | 18 - .../components/remote_base/onkyori_protocol.h | 11 - .../remote_base/panasonic_protocol.h | 13 - .../components/remote_base/pioneer_protocol.h | 13 - esphome/components/remote_base/rc5_protocol.h | 13 - .../components/remote_base/remote_base.cpp | 9 + esphome/components/remote_base/remote_base.h | 30 +- .../components/remote_base/roomba_protocol.h | 11 - .../remote_base/samsung36_protocol.h | 13 - .../components/remote_base/samsung_protocol.h | 13 - .../components/remote_base/sony_protocol.h | 13 - .../remote_base/symphony_protocol.h | 15 - .../remote_base/toshiba_ac_protocol.h | 13 - .../components/remote_base/toto_protocol.h | 15 - .../remote_transmitter/__init__.py | 13 + .../config/transmit_repeat.yaml | 41 ++ .../remote_transmitter/test_keeloq_action.py | 23 +- .../test_transmit_repeat.py | 27 + 36 files changed, 460 insertions(+), 660 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/transmit_repeat.yaml create mode 100644 tests/component_tests/remote_transmitter/test_transmit_repeat.py diff --git a/esphome/automation.py b/esphome/automation.py index 2f90cbfcbb..393a79422b 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,4 +1,4 @@ -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field import logging import string @@ -465,6 +465,22 @@ def register_apply_action( ) -> None: """Register an action that only forwards config values to its parent, with no C++ class. + See ``apply_action_builder``. + """ + register_action(name, ApplyAction, schema, synchronous=True)( + apply_action_builder(name, schema, *fields, call=call, id_key=id_key) + ) + + +def apply_action_builder( + name: str, + schema: cv.Schema, + *fields: ApplyField | ApplyCall, + call: str | None = None, + id_key: str = CONF_ID, +) -> Callable[..., Awaitable[MockObj]]: + """The builder ``register_apply_action`` registers, for a caller that wraps it. + Generates one static function with the parent (read from ``id_key``) and constants baked in, lambdas called inline with the trigger args, and an ``ApplyAction`` templated on it. A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names. @@ -515,7 +531,7 @@ def register_apply_action( action_id, cg.void, template_arg, lambda_args, statements ) - register_action(name, ApplyAction, schema, synchronous=True)(builder) + return builder def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool: diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index eca57bca8a..01e25714e7 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -44,10 +44,10 @@ from esphome.const import ( CONF_WAND_ID, CONF_ZERO, ) -from esphome.core import coroutine +from esphome.core import ID, coroutine from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType, SafeExpType, TemplateArgsType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] @@ -257,14 +257,23 @@ BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( ).extend(REMOTE_TRANSMITTABLE_SCHEMA) +def _transmit_action( + name: str, + schema: cv.Schema, + validate: Callable[[ConfigType], ConfigType] | None, +) -> tuple[str, cv.Schema, Any]: + """The action name, its schema and the schema with ``validate`` applied.""" + schema = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) + validator = schema if validate is None else cv.All(schema, validate) + return f"remote_transmitter.transmit_{name}", schema, validator + + 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) + action, _, validator = _transmit_action(name, schema, validate) registerer = automation.register_action( - f"remote_transmitter.transmit_{name}", + action, type_, validator, synchronous=True, @@ -289,6 +298,48 @@ def register_action( return decorator +def register_protocol_action( + name: str, + protocol: str, + schema: cv.Schema, + *fields: tuple[str, str, SafeExpType], + validate: Callable[[ConfigType], ConfigType] | None = None, +) -> None: + """Register ``transmit_`` with no C++ action class. + + One generated function fills ``Data`` from ``(conf_key, member, type_)`` fields + and sends it; the action is a core ``ApplyAction``. + """ + action, schema, validator = _transmit_action(name, schema, validate) + builder = automation.apply_action_builder( + action, + schema, + *( + automation.ApplyField(key, f"data.{member} = {{}}", type_) + for key, member, type_ in fields + ), + automation.ApplyField((CONF_REPEAT, CONF_TIMES), "set_send_times", cg.uint32), + automation.ApplyField( + (CONF_REPEAT, CONF_WAIT_TIME), "set_send_wait", cg.uint32 + ), + call=f"protocol_call<{ns.class_(f'{protocol}Protocol')}>", + id_key=CONF_TRANSMITTER_ID, + ) + + async def protocol_builder( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + _request_protocol_if_in_tree(name) + return await builder(config, action_id, template_arg, args) + + automation.register_action( + action, automation.ApplyAction, validator, synchronous=True + )(protocol_builder) + + def declare_protocol(name): data = ns.struct(f"{name}Data") binary_sensor_ = ns.class_(f"{name}BinarySensor", RemoteReceiverBinarySensorBase) @@ -372,9 +423,7 @@ async def build_dumpers(config): # Beo4 -Beo4Data, Beo4BinarySensor, Beo4Trigger, Beo4Action, Beo4Dumper = declare_protocol( - "Beo4" -) +Beo4Data, Beo4BinarySensor, Beo4Trigger, _, Beo4Dumper = declare_protocol("Beo4") BEO4_SCHEMA = cv.Schema( { cv.Required(CONF_SOURCE): cv.hex_uint8_t, @@ -408,14 +457,14 @@ def beo4_dumper(var, config): pass -@register_action("beo4", Beo4Action, BEO4_SCHEMA) -async def beo4_action(var, config, args): - template_ = await cg.templatable(config[CONF_SOURCE], args, cg.uint8) - cg.add(var.set_source(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint8) - cg.add(var.set_repeats(template_)) +register_protocol_action( + "beo4", + "Beo4", + BEO4_SCHEMA, + (CONF_SOURCE, "source", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + (CONF_COMMAND_REPEATS, "repeats", cg.uint8), +) # Brennenstuhl @@ -423,7 +472,7 @@ async def beo4_action(var, config, args): BrennenstuhlData, BrennenstuhlBinarySensor, BrennenstuhlTrigger, - BrennenstuhlAction, + _, BrennenstuhlDumper, ) = declare_protocol("Brennenstuhl") @@ -456,10 +505,12 @@ def brennenstuhl_dumper(var, config): pass -@register_action("brennenstuhl", BrennenstuhlAction, BRENNENSTUHL_SCHEMA) -async def brennenstuhl_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_code(template_)) +register_protocol_action( + "brennenstuhl", + "Brennenstuhl", + BRENNENSTUHL_SCHEMA, + (CONF_CODE, "code", cg.uint32), +) # ByronSX @@ -467,7 +518,7 @@ async def brennenstuhl_action(var, config, args): ByronSXData, ByronSXBinarySensor, ByronSXTrigger, - ByronSXAction, + _, ByronSXDumper, ) = declare_protocol("ByronSX") BYRONSX_SCHEMA = cv.Schema( @@ -503,12 +554,13 @@ def byronsx_dumper(var, config): pass -@register_action("byronsx", ByronSXAction, BYRONSX_SCHEMA) -async def byronsx_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "byronsx", + "ByronSX", + BYRONSX_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # CanalSat @@ -516,7 +568,7 @@ async def byronsx_action(var, config, args): CanalSatData, CanalSatBinarySensor, CanalSatTrigger, - CanalSatAction, + _, CanalSatDumper, ) = declare_protocol("CanalSat") CANALSAT_SCHEMA = cv.Schema( @@ -552,21 +604,21 @@ def canalsat_dumper(var, config): pass -@register_action("canalsat", CanalSatAction, CANALSAT_SCHEMA) -async def canalsat_action(var, config, args): - template_ = await cg.templatable(config[CONF_DEVICE], args, cg.uint8) - cg.add(var.set_device(template_)) - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "canalsat", + "CanalSat", + CANALSAT_SCHEMA, + (CONF_DEVICE, "device", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) ( CanalSatLDData, CanalSatLDBinarySensor, CanalSatLDTrigger, - CanalSatLDAction, + _, CanalSatLDDumper, ) = declare_protocol("CanalSatLD") CANALSATLD_SCHEMA = cv.Schema( @@ -602,14 +654,14 @@ def canalsatld_dumper(var, config): pass -@register_action("canalsatld", CanalSatLDAction, CANALSATLD_SCHEMA) -async def canalsatld_action(var, config, args): - template_ = await cg.templatable(config[CONF_DEVICE], args, cg.uint8) - cg.add(var.set_device(template_)) - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "canalsatld", + "CanalSatLD", + CANALSATLD_SCHEMA, + (CONF_DEVICE, "device", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # Coolix @@ -617,7 +669,7 @@ async def canalsatld_action(var, config, args): CoolixData, CoolixBinarySensor, CoolixTrigger, - CoolixAction, + _, CoolixDumper, ) = declare_protocol("Coolix") @@ -647,12 +699,13 @@ def coolix_binary_sensor(var, config): cg.add(var.set_data(cg.ArrayInitializer(0, config))) -@register_action("coolix", CoolixAction, COOLIX_BASE_SCHEMA) -async def coolix_action(var, config, args): - template_ = await cg.templatable(config[CONF_FIRST], args, cg.uint32) - cg.add(var.set_first(template_)) - template_ = await cg.templatable(config[CONF_SECOND], args, cg.uint32) - cg.add(var.set_second(template_)) +register_protocol_action( + "coolix", + "Coolix", + COOLIX_BASE_SCHEMA, + (CONF_FIRST, "first", cg.uint32), + (CONF_SECOND, "second", cg.uint32), +) @register_trigger("coolix", CoolixTrigger, CoolixData) @@ -666,9 +719,7 @@ def coolix_dumper(var, config): # Dish -DishData, DishBinarySensor, DishTrigger, DishAction, DishDumper = declare_protocol( - "Dish" -) +DishData, DishBinarySensor, DishTrigger, _, DishDumper = declare_protocol("Dish") DISH_SCHEMA = cv.Schema( { cv.Optional(CONF_ADDRESS, default=1): cv.int_range(min=1, max=16), @@ -700,18 +751,17 @@ def dish_dumper(var, config): pass -@register_action("dish", DishAction, DISH_SCHEMA) -async def dish_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "dish", + "Dish", + DISH_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # Dooya -DooyaData, DooyaBinarySensor, DooyaTrigger, DooyaAction, DooyaDumper = declare_protocol( - "Dooya" -) +DooyaData, DooyaBinarySensor, DooyaTrigger, _, DooyaDumper = declare_protocol("Dooya") DOOYA_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.hex_int_range(0, 16777215), @@ -747,22 +797,19 @@ def dooya_dumper(var, config): pass -@register_action("dooya", DooyaAction, DOOYA_SCHEMA) -async def dooya_action(var, config, args): - template_ = await cg.templatable(config[CONF_ID], args, cg.uint32) - cg.add(var.set_id(template_)) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - template_ = await cg.templatable(config[CONF_BUTTON], args, cg.uint8) - cg.add(var.set_button(template_)) - template_ = await cg.templatable(config[CONF_CHECK], args, cg.uint8) - cg.add(var.set_check(template_)) +register_protocol_action( + "dooya", + "Dooya", + DOOYA_SCHEMA, + (CONF_ID, "id", cg.uint32), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_BUTTON, "button", cg.uint8), + (CONF_CHECK, "check", cg.uint8), +) # Dyson -DysonData, DysonBinarySensor, DysonTrigger, DysonAction, DysonDumper = declare_protocol( - "Dyson" -) +DysonData, DysonBinarySensor, DysonTrigger, _, DysonDumper = declare_protocol("Dyson") DYSON_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.hex_uint16_t, @@ -794,16 +841,17 @@ def dyson_dumper(var, config): pass -@register_action("dyson", DysonAction, DYSON_SCHEMA) -async def dyson_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint16) - cg.add(var.set_code(template_)) - template_ = await cg.templatable(config[CONF_INDEX], args, cg.uint8) - cg.add(var.set_index(template_)) +register_protocol_action( + "dyson", + "Dyson", + DYSON_SCHEMA, + (CONF_CODE, "code", cg.uint16), + (CONF_INDEX, "index", cg.uint8), +) # JVC -JVCData, JVCBinarySensor, JVCTrigger, JVCAction, JVCDumper = declare_protocol("JVC") +JVCData, JVCBinarySensor, JVCTrigger, _, JVCDumper = declare_protocol("JVC") JVC_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_uint32_t}) @@ -829,14 +877,16 @@ def jvc_dumper(var, config): pass -@register_action("jvc", JVCAction, JVC_SCHEMA) -async def jvc_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) +register_protocol_action( + "jvc", + "JVC", + JVC_SCHEMA, + (CONF_DATA, "data", cg.uint32), +) # LG -LGData, LGBinarySensor, LGTrigger, LGAction, LGDumper = declare_protocol("LG") +LGData, LGBinarySensor, LGTrigger, _, LGDumper = declare_protocol("LG") LG_SCHEMA = cv.Schema( { cv.Required(CONF_DATA): cv.hex_uint32_t, @@ -868,12 +918,13 @@ def lg_dumper(var, config): pass -@register_action("lg", LGAction, LG_SCHEMA) -async def lg_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "lg", + "LG", + LG_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), +) # MagiQuest @@ -881,7 +932,7 @@ async def lg_action(var, config, args): MagiQuestData, MagiQuestBinarySensor, MagiQuestTrigger, - MagiQuestAction, + _, MagiQuestDumper, ) = declare_protocol("MagiQuest") @@ -916,12 +967,13 @@ def magiquest_dumper(var, config): pass -@register_action("magiquest", MagiQuestAction, MAGIQUEST_SCHEMA) -async def magiquest_action(var, config, args): - template_ = await cg.templatable(config[CONF_WAND_ID], args, cg.uint32) - cg.add(var.set_wand_id(template_)) - template_ = await cg.templatable(config[CONF_MAGNITUDE], args, cg.uint16) - cg.add(var.set_magnitude(template_)) +register_protocol_action( + "magiquest", + "MagiQuest", + MAGIQUEST_SCHEMA, + (CONF_WAND_ID, "wand_id", cg.uint32), + (CONF_MAGNITUDE, "magnitude", cg.uint16), +) # Microchip HCS301 KeeLoq OOK @@ -929,7 +981,7 @@ async def magiquest_action(var, config, args): KeeloqData, KeeloqBinarySensor, KeeloqTrigger, - KeeloqAction, + _, KeeloqDumper, ) = declare_protocol("Keeloq") KEELOQ_SCHEMA = cv.Schema( @@ -947,8 +999,8 @@ 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] + suffix = config.get(CONF_SUFFIX, 0) + bits = config.get(CONF_SUFFIX_BITS, 0) 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] @@ -958,10 +1010,9 @@ def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: 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), + # No defaults: an absent key leaves the data at 0 and emits nothing. + cv.Optional(CONF_SUFFIX): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), + cv.Optional(CONF_SUFFIX_BITS): cv.int_range(min=0, max=16), } ) @@ -989,28 +1040,22 @@ def keeloq_dumper(var, config): pass -@register_action( - "keeloq", KeeloqAction, KEELOQ_ACTION_SCHEMA, validate=_keeloq_suffix_fits +register_protocol_action( + "keeloq", + "Keeloq", + KEELOQ_ACTION_SCHEMA, + (CONF_ADDRESS, "address", cg.uint32), + (CONF_CODE, "encrypted", cg.uint32), + (CONF_COMMAND, "command", cg.uint8), + (CONF_LEVEL, "vlow", cg.bool_), + (CONF_SUFFIX, "suffix", cg.uint16), + (CONF_SUFFIX_BITS, "suffix_bits", cg.uint8), + 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_)) - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_encrypted(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - 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 -NECData, NECBinarySensor, NECTrigger, NECAction, NECDumper = declare_protocol("NEC") +NECData, NECBinarySensor, NECTrigger, _, NECDumper = declare_protocol("NEC") NEC_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint16_t, @@ -1044,19 +1089,19 @@ def nec_dumper(var, config): pass -@register_action("nec", NECAction, NEC_SCHEMA) -async def nec_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint16) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint16) - cg.add(var.set_command_repeats(template_)) +register_protocol_action( + "nec", + "NEC", + NEC_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_COMMAND, "command", cg.uint16), + (CONF_COMMAND_REPEATS, "command_repeats", cg.uint16), +) # Onkyo RI -OnkyoRIData, OnkyoRIBinarySensor, OnkyoRITrigger, OnkyoRIAction, OnkyoRIDumper = ( - declare_protocol("OnkyoRI") +OnkyoRIData, OnkyoRIBinarySensor, OnkyoRITrigger, _, OnkyoRIDumper = declare_protocol( + "OnkyoRI" ) ONKYORI_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_int_range(0, 0xFFF)}) @@ -1083,12 +1128,12 @@ def onkyori_dumper(var: MockObj, config: ConfigType) -> None: """The dumper takes no options.""" -@register_action("onkyori", OnkyoRIAction, ONKYORI_SCHEMA) -async def onkyori_action( - var: MockObj, config: ConfigType, args: TemplateArgsType -) -> None: - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint16) - cg.add(var.set_data(template_)) +register_protocol_action( + "onkyori", + "OnkyoRI", + ONKYORI_SCHEMA, + (CONF_DATA, "data", cg.uint16), +) # Pioneer @@ -1096,7 +1141,7 @@ async def onkyori_action( PioneerData, PioneerBinarySensor, PioneerTrigger, - PioneerAction, + _, PioneerDumper, ) = declare_protocol("Pioneer") PIONEER_SCHEMA = cv.Schema( @@ -1130,12 +1175,13 @@ def pioneer_dumper(var, config): pass -@register_action("pioneer", PioneerAction, PIONEER_SCHEMA) -async def pioneer_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint16) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint16) - cg.add(var.set_rc_code_2(template_)) +register_protocol_action( + "pioneer", + "Pioneer", + PIONEER_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint16), + (CONF_RC_CODE_2, "rc_code_2", cg.uint16), +) # Pronto @@ -1188,7 +1234,7 @@ async def pronto_action(var, config, args): GoboxData, GoboxBinarySensor, GoboxTrigger, - GoboxAction, + _, GoboxDumper, ) = declare_protocol("Gobox") GOBOX_SCHEMA = cv.Schema( @@ -1220,10 +1266,12 @@ def gobox_dumper(var, config): pass -@register_action("gobox", GoboxAction, GOBOX_SCHEMA) -async def gobox_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint64) - cg.add(var.set_code(template_)) +register_protocol_action( + "gobox", + "Gobox", + GOBOX_SCHEMA, + (CONF_CODE, "code", cg.uint64), +) # Roomba @@ -1231,7 +1279,7 @@ async def gobox_action(var, config, args): RoombaData, RoombaBinarySensor, RoombaTrigger, - RoombaAction, + _, RoombaDumper, ) = declare_protocol("Roomba") ROOMBA_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_uint8_t}) @@ -1259,16 +1307,16 @@ def roomba_dumper(var, config): pass -@register_action("roomba", RoombaAction, ROOMBA_SCHEMA) -async def roomba_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint8) - cg.add(var.set_data(template_)) +register_protocol_action( + "roomba", + "Roomba", + ROOMBA_SCHEMA, + (CONF_DATA, "data", cg.uint8), +) # Sony -SonyData, SonyBinarySensor, SonyTrigger, SonyAction, SonyDumper = declare_protocol( - "Sony" -) +SonyData, SonyBinarySensor, SonyTrigger, _, SonyDumper = declare_protocol("Sony") SONY_SCHEMA = cv.Schema( { cv.Required(CONF_DATA): cv.hex_uint32_t, @@ -1300,16 +1348,17 @@ def sony_dumper(var, config): pass -@register_action("sony", SonyAction, SONY_SCHEMA) -async def sony_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "sony", + "Sony", + SONY_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), +) # Symphony -SymphonyData, SymphonyBinarySensor, SymphonyTrigger, SymphonyAction, SymphonyDumper = ( +SymphonyData, SymphonyBinarySensor, SymphonyTrigger, _, SymphonyDumper = ( declare_protocol("Symphony") ) SYMPHONY_SCHEMA = cv.Schema( @@ -1344,14 +1393,14 @@ def symphony_dumper(var, config): pass -@register_action("symphony", SymphonyAction, SYMPHONY_SCHEMA) -async def symphony_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint8) - cg.add(var.set_repeats(template_)) +register_protocol_action( + "symphony", + "Symphony", + SYMPHONY_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), + (CONF_COMMAND_REPEATS, "repeats", cg.uint8), +) # Raw @@ -1430,7 +1479,7 @@ async def raw_action(var, config, args): DraytonData, DraytonBinarySensor, DraytonTrigger, - DraytonAction, + _, DraytonDumper, ) = declare_protocol("Drayton") DRAYTON_SCHEMA = cv.Schema( @@ -1466,18 +1515,18 @@ def drayton_dumper(var, config): pass -@register_action("drayton", DraytonAction, DRAYTON_SCHEMA) -async def drayton_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "drayton", + "Drayton", + DRAYTON_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # RC5 -RC5Data, RC5BinarySensor, RC5Trigger, RC5Action, RC5Dumper = declare_protocol("RC5") +RC5Data, RC5BinarySensor, RC5Trigger, _, RC5Dumper = declare_protocol("RC5") RC5_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.All(cv.hex_int, cv.Range(min=0, max=0x1F)), @@ -1509,12 +1558,13 @@ def rc5_dumper(var, config): pass -@register_action("rc5", RC5Action, RC5_SCHEMA) -async def rc5_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "rc5", + "RC5", + RC5_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # RC6 @@ -1873,7 +1923,7 @@ def rc_switch_dumper(var, config): SamsungData, SamsungBinarySensor, SamsungTrigger, - SamsungAction, + _, SamsungDumper, ) = declare_protocol("Samsung") SAMSUNG_SCHEMA = cv.Schema( @@ -1907,12 +1957,13 @@ def samsung_dumper(var, config): pass -@register_action("samsung", SamsungAction, SAMSUNG_SCHEMA) -async def samsung_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint64) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "samsung", + "Samsung", + SAMSUNG_SCHEMA, + (CONF_DATA, "data", cg.uint64), + (CONF_NBITS, "nbits", cg.uint8), +) # Samsung36 @@ -1920,7 +1971,7 @@ async def samsung_action(var, config, args): Samsung36Data, Samsung36BinarySensor, Samsung36Trigger, - Samsung36Action, + _, Samsung36Dumper, ) = declare_protocol("Samsung36") SAMSUNG36_SCHEMA = cv.Schema( @@ -1954,12 +2005,13 @@ def samsung36_dumper(var, config): pass -@register_action("samsung36", Samsung36Action, SAMSUNG36_SCHEMA) -async def samsung36_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint32) - cg.add(var.set_command(template_)) +register_protocol_action( + "samsung36", + "Samsung36", + SAMSUNG36_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_COMMAND, "command", cg.uint32), +) # Toshiba AC @@ -1967,7 +2019,7 @@ async def samsung36_action(var, config, args): ToshibaAcData, ToshibaAcBinarySensor, ToshibaAcTrigger, - ToshibaAcAction, + _, ToshibaAcDumper, ) = declare_protocol("ToshibaAc") TOSHIBAAC_SCHEMA = cv.Schema( @@ -2001,12 +2053,13 @@ def toshibaac_dumper(var, config): pass -@register_action("toshiba_ac", ToshibaAcAction, TOSHIBAAC_SCHEMA) -async def toshibaac_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint64) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint64) - cg.add(var.set_rc_code_2(template_)) +register_protocol_action( + "toshiba_ac", + "ToshibaAc", + TOSHIBAAC_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint64), + (CONF_RC_CODE_2, "rc_code_2", cg.uint64), +) # Panasonic @@ -2014,7 +2067,7 @@ async def toshibaac_action(var, config, args): PanasonicData, PanasonicBinarySensor, PanasonicTrigger, - PanasonicAction, + _, PanasonicDumper, ) = declare_protocol("Panasonic") PANASONIC_SCHEMA = cv.Schema( @@ -2048,18 +2101,17 @@ def panasonic_dumper(var, config): pass -@register_action("panasonic", PanasonicAction, PANASONIC_SCHEMA) -async def panasonic_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint32) - cg.add(var.set_command(template_)) +register_protocol_action( + "panasonic", + "Panasonic", + PANASONIC_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_COMMAND, "command", cg.uint32), +) # Nexa -NexaData, NexaBinarySensor, NexaTrigger, NexaAction, NexaDumper = declare_protocol( - "Nexa" -) +NexaData, NexaBinarySensor, NexaTrigger, _, NexaDumper = declare_protocol("Nexa") NEXA_SCHEMA = cv.Schema( { cv.Required(CONF_DEVICE): cv.hex_uint32_t, @@ -2097,13 +2149,16 @@ def nexa_dumper(var, config): pass -@register_action("nexa", NexaAction, NEXA_SCHEMA) -async def nexa_action(var, config, args): - cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint32))) - cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.uint8))) - cg.add(var.set_channel(await cg.templatable(config[CONF_CHANNEL], args, cg.uint8))) - cg.add(var.set_level(await cg.templatable(config[CONF_LEVEL], args, cg.uint8))) +register_protocol_action( + "nexa", + "Nexa", + NEXA_SCHEMA, + (CONF_DEVICE, "device", cg.uint32), + (CONF_GROUP, "group", cg.uint8), + (CONF_STATE, "state", cg.uint8), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_LEVEL, "level", cg.uint8), +) # Midea @@ -2210,7 +2265,7 @@ async def aeha_action(var, config, args): Hob2HoodData, Hob2HoodBinarySensor, Hob2HoodTrigger, - Hob2HoodAction, + _, Hob2HoodDumper, ) = declare_protocol("Hob2Hood") @@ -2252,12 +2307,12 @@ def hob2hood_dumper(var: MockObj, config: ConfigType) -> None: """The dumper takes no options.""" -@register_action("hob2hood", Hob2HoodAction, HOB2HOOD_SCHEMA) -async def hob2hood_action( - var: MockObj, config: ConfigType, args: TemplateArgsType -) -> None: - template_ = await cg.templatable(config[CONF_COMMAND], args, Hob2HoodCommand) - cg.add(var.set_command(template_)) +register_protocol_action( + "hob2hood", + "Hob2Hood", + HOB2HOOD_SCHEMA, + (CONF_COMMAND, "command", Hob2HoodCommand), +) # Haier @@ -2446,7 +2501,7 @@ async def mirage_action(var, config, args): TotoData, TotoBinarySensor, TotoTrigger, - TotoAction, + _, TotoDumper, ) = declare_protocol("Toto") @@ -2483,17 +2538,19 @@ def toto_dumper(var, config): pass -@register_action("toto", TotoAction, TOTO_SCHEMA) -async def Toto_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint8) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint8) - cg.add(var.set_rc_code_2(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - # Set toto-specific defaults (only if user didn't configure repeat) - if CONF_REPEAT not in config: - template_ = await cg.templatable(3, args, cg.uint32) - cg.add(var.set_send_times(template_)) - template_ = await cg.templatable(36000, args, cg.uint32) - cg.add(var.set_send_wait(template_)) +def _toto_default_repeat(config: ConfigType) -> ConfigType: + # Toto remotes send each frame 3 times, 36 ms apart + if CONF_REPEAT in config: + return config + return {**config, CONF_REPEAT: {CONF_TIMES: 3, CONF_WAIT_TIME: 36000}} + + +register_protocol_action( + "toto", + "Toto", + TOTO_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint8), + (CONF_RC_CODE_2, "rc_code_2", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + validate=_toto_default_repeat, +) diff --git a/esphome/components/remote_base/beo4_protocol.h b/esphome/components/remote_base/beo4_protocol.h index ed9d6aa671..539aa0d75b 100644 --- a/esphome/components/remote_base/beo4_protocol.h +++ b/esphome/components/remote_base/beo4_protocol.h @@ -23,19 +23,4 @@ class Beo4Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Beo4) -template class Beo4Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, source) - TEMPLATABLE_VALUE(uint8_t, command) - TEMPLATABLE_VALUE(uint8_t, repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - Beo4Data data{}; - data.source = this->source_.value(x...); - data.command = this->command_.value(x...); - data.repeats = this->repeats_.value(x...); - Beo4Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/brennenstuhl_protocol.h b/esphome/components/remote_base/brennenstuhl_protocol.h index bfea463b7d..f85df95305 100644 --- a/esphome/components/remote_base/brennenstuhl_protocol.h +++ b/esphome/components/remote_base/brennenstuhl_protocol.h @@ -20,15 +20,4 @@ class BrennenstuhlProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Brennenstuhl) -template class BrennenstuhlAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - BrennenstuhlData data{}; - data.code = this->code_.value(x...); - BrennenstuhlProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/byronsx_protocol.h b/esphome/components/remote_base/byronsx_protocol.h index c71390c267..67a2d5a06a 100644 --- a/esphome/components/remote_base/byronsx_protocol.h +++ b/esphome/components/remote_base/byronsx_protocol.h @@ -28,17 +28,4 @@ class ByronSXProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(ByronSX) -template class ByronSXAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ByronSXData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - ByronSXProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/canalsat_protocol.h b/esphome/components/remote_base/canalsat_protocol.h index 09bead18b3..31e4f5c47e 100644 --- a/esphome/components/remote_base/canalsat_protocol.h +++ b/esphome/components/remote_base/canalsat_protocol.h @@ -41,36 +41,6 @@ class CanalSatLDProtocol : public CanalSatBaseProtocol { DECLARE_REMOTE_PROTOCOL(CanalSat) -template class CanalSatAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, device) - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - CanalSatData data{}; - data.device = this->device_.value(x...); - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - CanalSatProtocol().encode(dst, data); - } -}; - DECLARE_REMOTE_PROTOCOL(CanalSatLD) -template class CanalSatLDAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, device) - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - CanalSatData data{}; - data.device = this->device_.value(x...); - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - CanalSatLDProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/coolix_protocol.h b/esphome/components/remote_base/coolix_protocol.h index 29a306ce29..d5ce29d436 100644 --- a/esphome/components/remote_base/coolix_protocol.h +++ b/esphome/components/remote_base/coolix_protocol.h @@ -28,12 +28,4 @@ class CoolixProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Coolix) -template class CoolixAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(uint32_t, first) - TEMPLATABLE_VALUE(uint32_t, second) - void encode(RemoteTransmitData *dst, Ts... x) override { - CoolixProtocol().encode(dst, {this->first_.value(x...), this->second_.value(x...)}); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dish_protocol.h b/esphome/components/remote_base/dish_protocol.h index f319b55f43..85ea1faa38 100644 --- a/esphome/components/remote_base/dish_protocol.h +++ b/esphome/components/remote_base/dish_protocol.h @@ -20,17 +20,4 @@ class DishProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dish) -template class DishAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DishData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - DishProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dooya_protocol.h b/esphome/components/remote_base/dooya_protocol.h index 954c3cf1d3..06f77f8050 100644 --- a/esphome/components/remote_base/dooya_protocol.h +++ b/esphome/components/remote_base/dooya_protocol.h @@ -27,21 +27,4 @@ class DooyaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dooya) -template class DooyaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, id) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, button) - TEMPLATABLE_VALUE(uint8_t, check) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DooyaData data{}; - data.id = this->id_.value(x...); - data.channel = this->channel_.value(x...); - data.button = this->button_.value(x...); - data.check = this->check_.value(x...); - DooyaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index 4e879f0f75..ef9dc39fb3 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -26,19 +26,4 @@ class DraytonProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Drayton) -template class DraytonAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DraytonData data{}; - data.address = this->address_.value(x...); - data.channel = this->channel_.value(x...); - data.command = this->command_.value(x...); - DraytonProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h index 663e50fb4b..5afa28e07b 100644 --- a/esphome/components/remote_base/dyson_protocol.h +++ b/esphome/components/remote_base/dyson_protocol.h @@ -28,17 +28,4 @@ class DysonProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dyson) -template class DysonAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, code) - TEMPLATABLE_VALUE(uint8_t, index) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DysonData data{}; - data.code = this->code_.value(x...); - data.index = this->index_.value(x...); - DysonProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index 0c8797af70..55e8137b75 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -38,15 +38,4 @@ class GoboxProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Gobox) -template class GoboxAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, code); - - void encode(RemoteTransmitData *dst, Ts... x) override { - GoboxData data{}; - data.code = this->code_.value(x...); - GoboxProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/hob2hood_protocol.h b/esphome/components/remote_base/hob2hood_protocol.h index 44bd873a65..dd3288103c 100644 --- a/esphome/components/remote_base/hob2hood_protocol.h +++ b/esphome/components/remote_base/hob2hood_protocol.h @@ -28,12 +28,4 @@ class Hob2HoodProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Hob2Hood) -template class Hob2HoodAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(Hob2HoodCommand, command) - void encode(RemoteTransmitData *dst, Ts... x) override { - Hob2HoodProtocol().encode(dst, Hob2HoodData{this->command_.value(x...)}); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/jvc_protocol.h b/esphome/components/remote_base/jvc_protocol.h index 5911664fc3..58991fc8f4 100644 --- a/esphome/components/remote_base/jvc_protocol.h +++ b/esphome/components/remote_base/jvc_protocol.h @@ -21,15 +21,4 @@ class JVCProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(JVC) -template class JVCAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - JVCData data{}; - data.data = this->data_.value(x...); - JVCProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 093f2904aa..e391212e42 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -32,25 +32,4 @@ class KeeloqProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Keeloq) -template class KeeloqAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, address) - 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{}; - data.address = this->address_.value(x...); - 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); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/lg_protocol.h b/esphome/components/remote_base/lg_protocol.h index 91dfbadb0c..41ca3a2b04 100644 --- a/esphome/components/remote_base/lg_protocol.h +++ b/esphome/components/remote_base/lg_protocol.h @@ -23,17 +23,4 @@ class LGProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(LG) -template class LGAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - LGData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - LGProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/magiquest_protocol.h b/esphome/components/remote_base/magiquest_protocol.h index f0d2410fe2..98a5fe2081 100644 --- a/esphome/components/remote_base/magiquest_protocol.h +++ b/esphome/components/remote_base/magiquest_protocol.h @@ -34,17 +34,4 @@ class MagiQuestProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(MagiQuest) -template class MagiQuestAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, magnitude) - TEMPLATABLE_VALUE(uint32_t, wand_id) - - void encode(RemoteTransmitData *dst, Ts... x) override { - MagiQuestData data{}; - data.magnitude = this->magnitude_.value(x...); - data.wand_id = this->wand_id_.value(x...); - MagiQuestProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nec_protocol.h b/esphome/components/remote_base/nec_protocol.h index 1337f7a8b3..849952c40f 100644 --- a/esphome/components/remote_base/nec_protocol.h +++ b/esphome/components/remote_base/nec_protocol.h @@ -21,19 +21,4 @@ class NECProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(NEC) -template class NECAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint16_t, command) - TEMPLATABLE_VALUE(uint16_t, command_repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - NECData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - data.command_repeats = this->command_repeats_.value(x...); - NECProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nexa_protocol.h b/esphome/components/remote_base/nexa_protocol.h index ebf85387b0..b4d2ec4030 100644 --- a/esphome/components/remote_base/nexa_protocol.h +++ b/esphome/components/remote_base/nexa_protocol.h @@ -31,22 +31,4 @@ class NexaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Nexa) -template class NexaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, device) - TEMPLATABLE_VALUE(uint8_t, group) - TEMPLATABLE_VALUE(uint8_t, state) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, level) - void encode(RemoteTransmitData *dst, Ts... x) override { - NexaData data{}; - data.device = this->device_.value(x...); - data.group = this->group_.value(x...); - data.state = this->state_.value(x...); - data.channel = this->channel_.value(x...); - data.level = this->level_.value(x...); - NexaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/onkyori_protocol.h b/esphome/components/remote_base/onkyori_protocol.h index c6f0f1dd97..55efb1e9a3 100644 --- a/esphome/components/remote_base/onkyori_protocol.h +++ b/esphome/components/remote_base/onkyori_protocol.h @@ -19,15 +19,4 @@ class OnkyoRIProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(OnkyoRI) -template class OnkyoRIAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - OnkyoRIData data{}; - data.data = this->data_.value(x...); - OnkyoRIProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/panasonic_protocol.h b/esphome/components/remote_base/panasonic_protocol.h index 84df3c08b7..da8e3a0ff7 100644 --- a/esphome/components/remote_base/panasonic_protocol.h +++ b/esphome/components/remote_base/panasonic_protocol.h @@ -23,17 +23,4 @@ class PanasonicProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Panasonic) -template class PanasonicAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint32_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - PanasonicData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - PanasonicProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_protocol.h b/esphome/components/remote_base/pioneer_protocol.h index d02bd3451f..b3d71d7ac9 100644 --- a/esphome/components/remote_base/pioneer_protocol.h +++ b/esphome/components/remote_base/pioneer_protocol.h @@ -20,17 +20,4 @@ class PioneerProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Pioneer) -template class PioneerAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, rc_code_1) - TEMPLATABLE_VALUE(uint16_t, rc_code_2) - - void encode(RemoteTransmitData *dst, Ts... x) override { - PioneerData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - PioneerProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5_protocol.h b/esphome/components/remote_base/rc5_protocol.h index f6f0f33c6e..d720fb11cc 100644 --- a/esphome/components/remote_base/rc5_protocol.h +++ b/esphome/components/remote_base/rc5_protocol.h @@ -21,17 +21,4 @@ class RC5Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(RC5) -template class RC5Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - RC5Data data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - RC5Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 7205fb6221..38db90c97b 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -182,6 +182,15 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) { /* RemoteTransmitterBase */ +void RemoteTransmitterBase::transmit_encoded_(EncodeFn encode, const void *data, uint32_t send_times, + uint32_t send_wait) { + auto call = this->transmit(); + encode(call.get_data(), data); + call.set_send_times(send_times); + call.set_send_wait(send_wait); + call.perform(); +} + void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait, [[maybe_unused]] uint16_t seq) { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE const auto &vec = this->temp_.get_data(); diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 36d2a44c50..be0c7a75c5 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -193,14 +193,34 @@ class RemoteTransmitterBase : public RemoteComponentBase { } template void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { - auto call = this->transmit(); - Protocol().encode(call.get_data(), data); - call.set_send_times(send_times); - call.set_send_wait(send_wait); - call.perform(); + this->transmit_encoded_(&encode_thunk, &data, send_times, send_wait); + } + /// Protocol data filled field by field, then sent by perform(); the call a generated transmit action builds. + template class ProtocolCall { + public: + explicit ProtocolCall(RemoteTransmitterBase *parent) : parent_(parent) {} + void set_send_times(uint32_t send_times) { this->send_times_ = send_times; } + void set_send_wait(uint32_t send_wait) { this->send_wait_ = send_wait; } + void perform() { this->parent_->transmit(this->data, this->send_times_, this->send_wait_); } + + typename Protocol::ProtocolData data{}; + + protected: + RemoteTransmitterBase *parent_; + uint32_t send_times_{1}; + uint32_t send_wait_{0}; + }; + template ProtocolCall protocol_call() { + return ProtocolCall(this); } protected: + using EncodeFn = void (*)(RemoteTransmitData *dst, const void *data); + // One out-of-line send path, so each transmit

() call site only fills its data. + void transmit_encoded_(EncodeFn encode, const void *data, uint32_t send_times, uint32_t send_wait); + template static void encode_thunk(RemoteTransmitData *dst, const void *data) { + Protocol().encode(dst, *static_cast(data)); + } void send_(uint32_t send_times, uint32_t send_wait, uint16_t seq); virtual void send_internal(uint32_t send_times, uint32_t send_wait) = 0; /// Platforms that report completion later wait out the previous frame here, before send_() diff --git a/esphome/components/remote_base/roomba_protocol.h b/esphome/components/remote_base/roomba_protocol.h index 8db025f812..a8fab14580 100644 --- a/esphome/components/remote_base/roomba_protocol.h +++ b/esphome/components/remote_base/roomba_protocol.h @@ -19,15 +19,4 @@ class RoombaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Roomba) -template class RoombaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - RoombaData data{}; - data.data = this->data_.value(x...); - RoombaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung36_protocol.h b/esphome/components/remote_base/samsung36_protocol.h index df4e1af8d8..c70a98b112 100644 --- a/esphome/components/remote_base/samsung36_protocol.h +++ b/esphome/components/remote_base/samsung36_protocol.h @@ -23,17 +23,4 @@ class Samsung36Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Samsung36) -template class Samsung36Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint32_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - Samsung36Data data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - Samsung36Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung_protocol.h b/esphome/components/remote_base/samsung_protocol.h index dfa22ff85c..47aaa08e3a 100644 --- a/esphome/components/remote_base/samsung_protocol.h +++ b/esphome/components/remote_base/samsung_protocol.h @@ -21,17 +21,4 @@ class SamsungProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Samsung) -template class SamsungAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SamsungData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - SamsungProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/sony_protocol.h b/esphome/components/remote_base/sony_protocol.h index f83b2908b6..9bf488099b 100644 --- a/esphome/components/remote_base/sony_protocol.h +++ b/esphome/components/remote_base/sony_protocol.h @@ -23,17 +23,4 @@ class SonyProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Sony) -template class SonyAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SonyData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - SonyProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/symphony_protocol.h b/esphome/components/remote_base/symphony_protocol.h index 40a5c2daec..56a4d7c6d3 100644 --- a/esphome/components/remote_base/symphony_protocol.h +++ b/esphome/components/remote_base/symphony_protocol.h @@ -24,19 +24,4 @@ class SymphonyProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Symphony) -template class SymphonyAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - TEMPLATABLE_VALUE(uint8_t, repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SymphonyData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - data.repeats = this->repeats_.value(x...); - SymphonyProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toshiba_ac_protocol.h b/esphome/components/remote_base/toshiba_ac_protocol.h index 35d5af314c..482b338cda 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.h +++ b/esphome/components/remote_base/toshiba_ac_protocol.h @@ -21,17 +21,4 @@ class ToshibaAcProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(ToshibaAc) -template class ToshibaAcAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, rc_code_1) - TEMPLATABLE_VALUE(uint64_t, rc_code_2) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ToshibaAcData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - ToshibaAcProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 8e965a5c73..c845087684 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -23,19 +23,4 @@ class TotoProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Toto) -template class TotoAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, rc_code_1) - TEMPLATABLE_VALUE(uint8_t, rc_code_2) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - TotoData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - data.command = this->command_.value(x...); - TotoProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/tests/component_tests/remote_transmitter/__init__.py b/tests/component_tests/remote_transmitter/__init__.py index e69de29bb2..7b2d567ec7 100644 --- a/tests/component_tests/remote_transmitter/__init__.py +++ b/tests/component_tests/remote_transmitter/__init__.py @@ -0,0 +1,13 @@ +"""Helpers for the remote_transmitter component tests.""" + +import re + +_PROTOCOL_CALL_BODY = re.compile( + r"auto apply_call = \S+->protocol_call\(\);\n(.*?)apply_call\.perform\(\);", + re.DOTALL, +) + + +def protocol_call_bodies(main_cpp: str) -> list[str]: + """The statements of each generated transmit action, in config order.""" + return _PROTOCOL_CALL_BODY.findall(main_cpp) diff --git a/tests/component_tests/remote_transmitter/config/transmit_repeat.yaml b/tests/component_tests/remote_transmitter/config/transmit_repeat.yaml new file mode 100644 index 0000000000..5cd9227e6d --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/transmit_repeat.yaml @@ -0,0 +1,41 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: Toto default repeat + on_press: + - remote_transmitter.transmit_toto: + command: 0xEC + rc_code_1: 0x0D + rc_code_2: 0x0D + - platform: template + name: Toto explicit repeat + on_press: + - remote_transmitter.transmit_toto: + command: 0xEC + rc_code_1: 0x0D + rc_code_2: 0x0D + repeat: + times: 2 + wait_time: 20ms + - platform: template + name: NEC default wait + on_press: + - remote_transmitter.transmit_nec: + address: 0x4242 + command: 0x8484 + repeat: 4 + - platform: template + name: NEC once + on_press: + - remote_transmitter.transmit_nec: + address: 0x4242 + command: 0x8484 diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py index aafab1f2c8..cbc1efb0a6 100644 --- a/tests/component_tests/remote_transmitter/test_keeloq_action.py +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -1,18 +1,23 @@ -"""transmit_keeloq sets the suffix only when suffix bits are sent.""" +"""transmit_keeloq sets the suffix only when the config names it.""" from collections.abc import Callable from pathlib import Path +from tests.component_tests.remote_transmitter import protocol_call_bodies -def test_keeloq_suffix_is_set_only_with_suffix_bits( + +def test_keeloq_suffix_is_set_only_when_configured( 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 + bodies = protocol_call_bodies(main_cpp) + assert len(bodies) == 3 + plain, constant, lambda_ = bodies + # The plain word fills only the HCS301 fields. + assert "data.suffix" not in plain + assert "apply_call.data.suffix = 0x2A;" in constant + assert "apply_call.data.suffix_bits = 6;" in constant + # A lambda is called inline. + assert "apply_call.data.suffix = static_cast(0x2A);" in lambda_ + assert "apply_call.data.suffix_bits = static_cast(6);" in lambda_ diff --git a/tests/component_tests/remote_transmitter/test_transmit_repeat.py b/tests/component_tests/remote_transmitter/test_transmit_repeat.py new file mode 100644 index 0000000000..691c9e6b4e --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_transmit_repeat.py @@ -0,0 +1,27 @@ +"""repeat on a generated transmit action, and toto's default.""" + +from collections.abc import Callable +from pathlib import Path + +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_transmit_repeat( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("transmit_repeat.yaml")) + bodies = protocol_call_bodies(main_cpp) + assert len(bodies) == 4 + toto_default, toto_explicit, nec_repeat, nec_once = bodies + # toto sends 3 times 36 ms apart unless the config says otherwise. + assert "apply_call.set_send_times(3);" in toto_default + assert "apply_call.set_send_wait(36000);" in toto_default + assert "apply_call.set_send_times(2);" in toto_explicit + assert "apply_call.set_send_wait(20000);" in toto_explicit + # A bare count gets the 25 ms default wait, in microseconds. + assert "apply_call.set_send_times(4);" in nec_repeat + assert "apply_call.set_send_wait(25000);" in nec_repeat + # Without repeat neither setter is emitted; the call sends once. + assert "set_send_times" not in nec_once + assert "set_send_wait" not in nec_once From 32f416109e69a6d5a71a2ce41f4745a359527d77 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:05:16 +0000 Subject: [PATCH 149/268] Bump aioesphomeapi from 46.9.0 to 46.10.0 (#20563) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 612bda6b1a..a9dbaa3b8d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.4.0 click==8.5.0 -aioesphomeapi==46.9.0 +aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 4688df3b94aee0f794c9ed3a93ad4ae8bb19474f Mon Sep 17 00:00:00 2001 From: Harald Nagel Date: Sun, 11 Oct 2026 20:06:46 -0700 Subject: [PATCH 150/268] [mcp9808] Remove k7hpn as codeowner (#20561) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 - esphome/components/mcp9808/sensor.py | 1 - 2 files changed, 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 4264479349..c75693d2d3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -347,7 +347,6 @@ esphome/components/mcp4461/* @p1ngb4ck esphome/components/mcp4728/* @berfenger esphome/components/mcp47a1/* @jesserockz esphome/components/mcp9600/* @mreditor97 -esphome/components/mcp9808/* @k7hpn esphome/components/md5/* @esphome/core esphome/components/mdns/* @esphome/core esphome/components/media_player/* @jesserockz diff --git a/esphome/components/mcp9808/sensor.py b/esphome/components/mcp9808/sensor.py index 1daaa9c131..2238e3d853 100644 --- a/esphome/components/mcp9808/sensor.py +++ b/esphome/components/mcp9808/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -CODEOWNERS = ["@k7hpn"] DEPENDENCIES = ["i2c"] mcp9808_ns = cg.esphome_ns.namespace("mcp9808") From 0c6543c4a61d8685d9d6eafb905e051f8a5bf4a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 17:38:38 -1000 Subject: [PATCH 151/268] [remote_base] Name transmit as a template in ProtocolCall::perform (#20566) --- esphome/components/remote_base/remote_base.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index be0c7a75c5..fc2f3a7130 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -201,7 +201,7 @@ class RemoteTransmitterBase : public RemoteComponentBase { explicit ProtocolCall(RemoteTransmitterBase *parent) : parent_(parent) {} void set_send_times(uint32_t send_times) { this->send_times_ = send_times; } void set_send_wait(uint32_t send_wait) { this->send_wait_ = send_wait; } - void perform() { this->parent_->transmit(this->data, this->send_times_, this->send_wait_); } + void perform() { this->parent_->template transmit(this->data, this->send_times_, this->send_wait_); } typename Protocol::ProtocolData data{}; From 698935c3213eae0900394744707d8d47b80a6fea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 17:38:41 -1000 Subject: [PATCH 152/268] [seeed_mr24hpc1] Remove the duplicate startup delay constant left by the beta merge (#20569) --- esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 832c9dc1a3..8d5103cbba 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -11,8 +11,6 @@ ESPHOME_LOG_TAG(TAG, "seeed_mr24hpc1"); static constexpr uint32_t STARTUP_DELAY_MS = 1000; -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() { From 5c1c946e883549e9b14600e1d7fc3f9760ac4a64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 18:09:11 -1000 Subject: [PATCH 153/268] [wake_on_lan] Use the socket UDP API on ESP8266 and RP2040 (#20556) --- esphome/components/wake_on_lan/button.py | 15 ++++------ .../components/wake_on_lan/wake_on_lan.cpp | 28 ++++--------------- esphome/components/wake_on_lan/wake_on_lan.h | 12 ++------ 3 files changed, 12 insertions(+), 43 deletions(-) diff --git a/esphome/components/wake_on_lan/button.py b/esphome/components/wake_on_lan/button.py index 5e756d0057..f5d15f3cfa 100644 --- a/esphome/components/wake_on_lan/button.py +++ b/esphome/components/wake_on_lan/button.py @@ -1,33 +1,28 @@ import esphome.codegen as cg -from esphome.components import button +from esphome.components import button, socket import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE from esphome.types import ConfigType +AUTO_LOAD = ["socket"] DEPENDENCIES = ["network"] -def AUTO_LOAD() -> list[str]: - if CORE.is_esp8266 or CORE.is_rp2: - return [] - return ["socket"] - - CONF_TARGET_MAC_ADDRESS = "target_mac_address" wake_on_lan_ns = cg.esphome_ns.namespace("wake_on_lan") WakeOnLanButton = wake_on_lan_ns.class_("WakeOnLanButton", button.Button, cg.Component) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( button.button_schema(WakeOnLanButton) .extend(cv.COMPONENT_SCHEMA) .extend( { cv.Required(CONF_TARGET_MAC_ADDRESS): cv.mac_address, } - ) + ), + socket.consume_sockets(1, "wake_on_lan", socket.SocketType.UDP), ) diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index 52dfe98aad..3815312c68 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -1,7 +1,6 @@ #include "wake_on_lan.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/core/log.h" -#include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" namespace esphome::wake_on_lan { @@ -25,12 +24,15 @@ void WakeOnLanButton::dump_config() { } void WakeOnLanButton::press_action() { + if (this->broadcast_socket_ == nullptr) { + ESP_LOGW(TAG, "Socket not available"); + return; + } if (!network::is_connected()) { ESP_LOGW(TAG, "Network not connected"); return; } ESP_LOGI(TAG, "Sending Wake-on-LAN Packet"); -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) struct sockaddr_storage saddr {}; auto addr_len = socket::set_sockaddr(reinterpret_cast(&saddr), sizeof(saddr), "255.255.255.255", this->port_); @@ -47,29 +49,10 @@ void WakeOnLanButton::press_action() { addr_len) <= 0) { ESP_LOGW(TAG, "sendto() error %d", errno); } -#else - IPAddress broadcast = IPAddress(255, 255, 255, 255); - for (auto ip : esphome::network::get_ip_addresses()) { - if (ip.is_ip4()) { - if (this->udp_client_.beginPacketMulticast(broadcast, 9, ip, 128) != 0) { - this->udp_client_.write(PREFIX, 6); - for (size_t i = 0; i < 16; i++) { - this->udp_client_.write(macaddr_, 6); - } - if (this->udp_client_.endPacket() != 0) - return; - ESP_LOGW(TAG, "WOL broadcast failed"); - return; - } - } - } - ESP_LOGW(TAG, "No ip4 addresses to broadcast to"); -#endif } void WakeOnLanButton::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -85,7 +68,6 @@ void WakeOnLanButton::setup() { if (err != 0) { this->status_set_warning(LOG_STR("Socket unable to set broadcast")); } -#endif } } // namespace esphome::wake_on_lan diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index cef60c54f8..6f89754f56 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -2,13 +2,9 @@ #include "esphome/core/defines.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" +#include "esphome/components/socket/socket.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) -#include "esphome/components/socket/socket.h" -#else -#include "WiFiUdp.h" -#endif namespace esphome::wake_on_lan { @@ -21,11 +17,7 @@ class WakeOnLanButton final : public button::Button, public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } protected: -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr broadcast_socket_{}; -#else - WiFiUDP udp_client_{}; -#endif + std::unique_ptr broadcast_socket_{}; void press_action() override; uint16_t port_{9}; uint8_t macaddr_[MAC_ADDRESS_SIZE]; From 3f21ddf6ffce29d041ce8973184b75dbe745bf08 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:12:22 +1100 Subject: [PATCH 154/268] [lvgl] Add a color picker widget (#17883) Co-authored-by: Claude Opus 5 Co-authored-by: J. Nick Koston --- esphome/components/lvgl/color_picker.h | 625 +++++++++++++++++ esphome/components/lvgl/defines.py | 5 +- esphome/components/lvgl/gradient.py | 4 +- esphome/components/lvgl/styles.py | 26 +- esphome/components/lvgl/widgets/__init__.py | 128 +++- .../components/lvgl/widgets/color_picker.py | 240 +++++++ esphome/core/defines.h | 11 + .../lvgl/config/color_picker.yaml | 133 ++++ .../lvgl/config/color_picker_theme.yaml | 48 ++ .../lvgl/config/named_styles.yaml | 47 ++ .../component_tests/lvgl/test_color_picker.py | 628 ++++++++++++++++++ .../component_tests/lvgl/test_named_styles.py | 57 ++ tests/components/lvgl/lvgl-package.yaml | 52 ++ tests/components/lvgl/test.host.yaml | 5 + .../fixtures/lvgl_color_picker_render.yaml | 82 +++ .../test_lvgl_color_picker_render.py | 105 +++ 16 files changed, 2171 insertions(+), 25 deletions(-) create mode 100644 esphome/components/lvgl/color_picker.h create mode 100644 esphome/components/lvgl/widgets/color_picker.py create mode 100644 tests/component_tests/lvgl/config/color_picker.yaml create mode 100644 tests/component_tests/lvgl/config/color_picker_theme.yaml create mode 100644 tests/component_tests/lvgl/config/named_styles.yaml create mode 100644 tests/component_tests/lvgl/test_color_picker.py create mode 100644 tests/component_tests/lvgl/test_named_styles.py create mode 100644 tests/integration/fixtures/lvgl_color_picker_render.yaml create mode 100644 tests/integration/test_lvgl_color_picker_render.py diff --git a/esphome/components/lvgl/color_picker.h b/esphome/components/lvgl/color_picker.h new file mode 100644 index 0000000000..e2392694f0 --- /dev/null +++ b/esphome/components/lvgl/color_picker.h @@ -0,0 +1,625 @@ +#pragma once + +#include "lvgl_esphome.h" +#include "esphome/core/color.h" + +#include +#include +#include +#include +#include + +#ifdef USE_LVGL_COLOR_PICKER + +namespace esphome::lvgl { + +// The hue gradients need seven stops; gradient.py raises the limit when a colour picker is in use. +static_assert(LV_GRADIENT_MAX_STOPS >= 7, "color_picker needs LV_GRADIENT_MAX_STOPS >= 7"); + +class LvColorPickerType : public LvCompound { + public: + enum SliderIndex : size_t { + SLIDER_HUE, + SLIDER_SATURATION, + SLIDER_BRIGHTNESS, + SLIDER_RED, + SLIDER_GREEN, + SLIDER_BLUE, + SLIDER_COUNT, + }; + + enum SliderFlag : uint8_t { + SLIDER_FLAG_HUE = 1 << SLIDER_HUE, + SLIDER_FLAG_SATURATION = 1 << SLIDER_SATURATION, + SLIDER_FLAG_BRIGHTNESS = 1 << SLIDER_BRIGHTNESS, + SLIDER_FLAG_RED = 1 << SLIDER_RED, + SLIDER_FLAG_GREEN = 1 << SLIDER_GREEN, + SLIDER_FLAG_BLUE = 1 << SLIDER_BLUE, + }; + + explicit LvColorPickerType(uint8_t sliders, bool use_wheel) : sliders_mask_(sliders), use_wheel_(use_wheel) {} + + // Returns null for a slider the widget was not built with. + lv_obj_t *get_slider(size_t index) const { return this->sliders_[index]; } + + void set_tint_knobs(bool tint_knobs) { this->tint_knobs_ = tint_knobs; } + + void set_color(lv_color_t color) { + lv_color32_t c32 = lv_color_to_32(color, LV_OPA_COVER); + this->state_ = Color(c32.red, c32.green, c32.blue); + if (this->obj != nullptr) + this->update_color_(); + } + Color get_color() const { return this->state_; } + + void set_obj(lv_obj_t *outer) override { + LvCompound::set_obj(outer); + init_shared(); + // The far end of these two follows the current hue. + init_bar(&this->saturation_bar_, LV_GRAD_DIR_HOR, lv_color_white(), lv_color_hex(0xFF0000)); + init_bar(&this->brightness_bar_, LV_GRAD_DIR_HOR, lv_color_black(), lv_color_white()); + + auto channels = static_cast(this->enabled_(SLIDER_RED)) + + static_cast(this->enabled_(SLIDER_GREEN)) + + static_cast(this->enabled_(SLIDER_BLUE)); + + // No layout on the outer object, so LV_SIZE_CONTENT uses the size from self_size_cb. + lv_obj_add_event_cb(outer, self_size_cb, LV_EVENT_GET_SELF_SIZE, nullptr); + lv_obj_add_event_cb(outer, size_changed_cb, LV_EVENT_SIZE_CHANGED, this); + lv_obj_set_style_pad_all(outer, KNOB_OVERHANG, LV_PART_MAIN); + + int32_t left_width = 0; + lv_obj_t *left = nullptr; + if (this->enabled_(SLIDER_HUE)) { + left_width = HUE_WIDTH_PCT; + left = this->create_slider_(outer, SLIDER_HUE, "Hue", true, &hue_bar, 360); + lv_obj_set_size(left, lv_pct(HUE_WIDTH_PCT), lv_pct(100)); + } +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + else if (this->use_wheel_) { + left_width = WHEEL_WIDTH_PCT; + left = this->create_wheel_(outer); + lv_obj_set_size(left, lv_pct(WHEEL_WIDTH_PCT), lv_pct(WHEEL_WIDTH_PCT)); + } +#endif + if (left != nullptr) { + lv_obj_set_style_align(left, LV_ALIGN_LEFT_MID, LV_PART_MAIN); + // A narrow hue column may be slimmer than its label. + lv_obj_add_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + } + + // The text rows keep their natural height and the bars between them take what is left, + // so a small widget gets thinner bars rather than clipped text. + auto *middle = create_flex(outer, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START); + lv_obj_set_style_align(middle, LV_ALIGN_RIGHT_MID, LV_PART_MAIN); + lv_obj_set_style_pad_row(middle, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_size(middle, lv_pct(100 - left_width), lv_pct(100)); + + // A dark bar needs light text, unlike every other value label. + if (this->enabled_(SLIDER_BRIGHTNESS)) + this->create_row_slider_(middle, SLIDER_BRIGHTNESS, "Brightness", &this->brightness_bar_, lv_color_white()); + + auto *inner = create_flex(middle, LV_FLEX_FLOW_ROW, LV_FLEX_ALIGN_START); + lv_obj_set_width(inner, lv_pct(100)); + lv_obj_set_flex_grow(inner, 1); + + // The channel bars keep a fixed width each, so dropping one widens the swatch. + auto channels_width = CHANNEL_WIDTH_PCT * channels; + auto *indicator_container = create_flex(inner, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER); + lv_obj_set_size(indicator_container, lv_pct(CONTENT_WIDTH_PCT - channels_width), lv_pct(100)); + lv_obj_set_style_pad_ver(indicator_container, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_style_pad_row(indicator_container, KNOB_OVERHANG, LV_PART_MAIN); + + this->color_text_ = lv_label_create(indicator_container); + this->color_indicator_ = lv_obj_create(indicator_container); + lv_obj_set_style_border_color(this->color_indicator_, lv_color_hex(0x808080), LV_PART_MAIN); + lv_obj_set_style_border_width(this->color_indicator_, 1, LV_PART_MAIN); + lv_obj_set_style_radius(this->color_indicator_, 0, LV_PART_MAIN); + lv_obj_set_width(this->color_indicator_, lv_pct(90)); + lv_obj_set_flex_grow(this->color_indicator_, 1); + + if (channels != 0) { + auto *rgb_container = create_flex(inner, LV_FLEX_FLOW_ROW, LV_FLEX_ALIGN_SPACE_BETWEEN); + lv_obj_set_size(rgb_container, lv_pct(channels_width), lv_pct(100)); + struct ChannelInfo { + SliderIndex index; + const char *name; + const lv_grad_dsc_t *grad; + }; + const std::array channel_info{{ + {SLIDER_RED, "R", &red_bar}, + {SLIDER_GREEN, "G", &green_bar}, + {SLIDER_BLUE, "B", &blue_bar}, + }}; + for (const auto &channel : channel_info) { + if (!this->enabled_(channel.index)) + continue; + auto *container = this->create_slider_(rgb_container, channel.index, channel.name, true, channel.grad, 255); + lv_obj_set_size(container, lv_pct(100 / channels), lv_pct(100)); + this->add_value_label_(channel.index, lv_color_black()); + } + } + + if (this->enabled_(SLIDER_SATURATION)) + this->create_row_slider_(middle, SLIDER_SATURATION, "Saturation", &this->saturation_bar_, lv_color_black()); + + this->update_color_(); + + for (size_t index = 0; index < SLIDER_COUNT; index++) { + if (this->sliders_[index] == nullptr) + continue; + lv_obj_add_event_cb(this->sliders_[index], index <= SLIDER_BRIGHTNESS ? hsv_changed_cb : rgb_changed_cb, + LV_EVENT_VALUE_CHANGED, this); + } + + bubble_events(outer); + } + + protected: + // LVGL keeps only pointers to gradients and styles, so one copy serves every colour picker. + static void init_shared() { + if (shared_ready) + return; + shared_ready = true; + + // Clockwise from red at 3 o'clock, matching how lv_color_hsv_to_rgb() reads the angle. + std::array hues{lv_color_hex(0xFF0000), lv_color_hex(0xFFFF00), lv_color_hex(0x00FF00), + lv_color_hex(0x00FFFF), lv_color_hex(0x0000FF), lv_color_hex(0xFF00FF), + lv_color_hex(0xFF0000)}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + lv_grad_conical_init(&wheel_hue_grad, lv_pct(50), lv_pct(50), 0, 360, LV_GRAD_EXTEND_PAD); + lv_grad_init_stops(&wheel_hue_grad, hues.data(), nullptr, nullptr, static_cast(hues.size())); + // White at the centre fading out at the rim, giving saturation over the hue wheel. + const lv_color_t whites[] = {lv_color_white(), lv_color_white()}; + const lv_opa_t white_opas[] = {LV_OPA_COVER, LV_OPA_TRANSP}; + lv_grad_radial_init(&wheel_white_grad, lv_pct(50), lv_pct(50), lv_pct(100), lv_pct(50), LV_GRAD_EXTEND_PAD); + lv_grad_init_stops(&wheel_white_grad, whites, white_opas, nullptr, 2); +#endif + // A vertical slider has its minimum at the bottom, so the hue bar runs the other way. + std::reverse(hues.begin(), hues.end()); + lv_grad_vertical_init(&hue_bar); + lv_grad_init_stops(&hue_bar, hues.data(), nullptr, nullptr, static_cast(hues.size())); + + init_bar(&red_bar, LV_GRAD_DIR_VER, lv_color_hex(0xFF0000), lv_color_white()); + init_bar(&green_bar, LV_GRAD_DIR_VER, lv_color_hex(0x00FF00), lv_color_white()); + init_bar(&blue_bar, LV_GRAD_DIR_VER, lv_color_hex(0x0000FF), lv_color_white()); + + lv_style_init(&plain_box); + lv_style_set_bg_opa(&plain_box, LV_OPA_TRANSP); + lv_style_set_border_width(&plain_box, 0); + lv_style_set_pad_all(&plain_box, 0); + lv_style_set_pad_row(&plain_box, 0); + lv_style_set_pad_column(&plain_box, 0); + + init_slider_styles(&slider_vert, &slider_vert_knob, true); + init_slider_styles(&slider_horz, &slider_horz_knob, false); + } + + static void init_bar(lv_grad_dsc_t *grad, lv_grad_dir_t dir, lv_color_t from, lv_color_t to) { + const lv_color_t colors[] = {from, to}; + grad->dir = dir; + lv_grad_init_stops(grad, colors, nullptr, nullptr, 2); + } + + static void init_slider_styles(lv_style_t *bar, lv_style_t *knob, bool vertical) { + lv_style_init(bar); + lv_style_set_align(bar, LV_ALIGN_CENTER); + lv_style_set_bg_opa(bar, LV_OPA_COVER); + lv_style_set_pad_all(bar, 0); + lv_style_set_radius(bar, 0); + // A horizontal bar's thickness is set by update_bar_thickness_(). + if (vertical) { + lv_style_set_width(bar, lv_pct(50)); + lv_style_set_height(bar, lv_pct(90)); + } else { + lv_style_set_width(bar, lv_pct(90)); + } + + lv_style_init(knob); + lv_style_set_bg_color(knob, lv_color_white()); + lv_style_set_border_width(knob, 1); + lv_style_set_outline_color(knob, lv_color_black()); + lv_style_set_radius(knob, 0); + // Both axes are set, or the theme picks a knob size the layout has not left room for. + int32_t along = -KNOB_TRIM; + int32_t across = KNOB_OVERHANG; + lv_style_set_pad_top(knob, vertical ? along : across); + lv_style_set_pad_bottom(knob, vertical ? along : across); + lv_style_set_pad_left(knob, vertical ? across : along); + lv_style_set_pad_right(knob, vertical ? across : along); + } + + // A borderless, unpadded container. + static lv_obj_t *create_box(lv_obj_t *parent) { + auto *obj = lv_obj_create(parent); + lv_obj_add_style(obj, &plain_box, LV_PART_MAIN); + lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + return obj; + } + + static lv_obj_t *create_flex(lv_obj_t *parent, lv_flex_flow_t flow, lv_flex_align_t main_place, + lv_flex_align_t track_place = LV_FLEX_ALIGN_START) { + auto *obj = create_box(parent); + lv_obj_set_layout(obj, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(obj, flow); + lv_obj_set_flex_align(obj, main_place, LV_FLEX_ALIGN_CENTER, track_place); + return obj; + } + + // Preferred size, in lines of the text font. The widget is square. + static constexpr int SELF_SIZE_FONT_MULTIPLE = 16; + + // At either end of its travel half the knob lies outside the bar. KNOB_TRIM pulls it back + // along the bar, and KNOB_OVERHANG makes it stand out across the bar. + static constexpr int KNOB_TRIM = 4; + static constexpr int KNOB_OVERHANG = 4; + + // Layout proportions, as percentages. + static constexpr int32_t HUE_WIDTH_PCT = 15; + static constexpr int32_t CHANNEL_WIDTH_PCT = 19; + static constexpr int32_t CONTENT_WIDTH_PCT = 99; + static constexpr int32_t WHEEL_WIDTH_PCT = 45; + static constexpr int32_t WHEEL_HANDLE_SIZE = 14; + + bool enabled_(SliderIndex index) const { return (this->sliders_mask_ & (1 << index)) != 0; } + bool has_(SliderIndex index) const { return this->sliders_[index] != nullptr; } + bool can_change_saturation_() const { + return this->use_wheel_ || (this->sliders_mask_ & + (SLIDER_FLAG_SATURATION | SLIDER_FLAG_RED | SLIDER_FLAG_GREEN | SLIDER_FLAG_BLUE)) != 0; + } + bool can_change_brightness_() const { + return (this->sliders_mask_ & (SLIDER_FLAG_BRIGHTNESS | SLIDER_FLAG_RED | SLIDER_FLAG_GREEN | SLIDER_FLAG_BLUE)) != + 0; + } + + // A slider the widget was built without keeps the value held internally. + int32_t slider_value_(SliderIndex index, int32_t fallback) const { + return this->has_(index) ? lv_slider_get_value(this->sliders_[index]) : fallback; + } + + void set_slider_value_(SliderIndex index, int32_t value) { + if (this->has_(index)) + lv_slider_set_value(this->sliders_[index], value, LV_ANIM_OFF); + } + + // Builds a slider with a name label above it, and returns its container. + lv_obj_t *create_slider_(lv_obj_t *parent, SliderIndex index, const char *name, bool vertical, + const lv_grad_dsc_t *grad, int32_t max) { + // A column keeps the label on its own row however large the font is. + auto *container = create_flex(parent, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER); + if (vertical) { + // How far the knob reaches past the ends depends on the font, so follow style changes. + update_knob_clearance(container); + lv_obj_add_event_cb(container, knob_clearance_cb, LV_EVENT_STYLE_CHANGED, nullptr); + } else { + lv_obj_set_style_pad_bottom(container, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_style_pad_row(container, KNOB_OVERHANG, LV_PART_MAIN); + } + lv_label_set_text(lv_label_create(container), name); + + auto *slider = lv_slider_create(container); + this->sliders_[index] = slider; + lv_obj_add_style(slider, vertical ? &slider_vert : &slider_horz, LV_PART_MAIN); + lv_obj_set_style_bg_grad(slider, grad, LV_PART_MAIN); + lv_obj_set_style_bg_opa(slider, LV_OPA_TRANSP, LV_PART_INDICATOR); + lv_obj_add_style(slider, vertical ? &slider_vert_knob : &slider_horz_knob, LV_PART_KNOB); + if (vertical) + lv_obj_set_flex_grow(slider, 1); + lv_slider_set_range(slider, 0, max); + lv_slider_set_mode(slider, LV_SLIDER_MODE_NORMAL); + return container; + } + + void create_row_slider_(lv_obj_t *parent, SliderIndex index, const char *name, const lv_grad_dsc_t *grad, + lv_color_t text_color) { + auto *container = this->create_slider_(parent, index, name, false, grad, 100); + lv_obj_set_size(container, lv_pct(100), LV_SIZE_CONTENT); + this->add_value_label_(index, text_color); + } + +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // A full-size circle filled with a gradient. + static lv_obj_t *create_disc(lv_obj_t *parent, const lv_grad_dsc_t *grad) { + auto *disc = create_box(parent); + lv_obj_set_size(disc, lv_pct(100), lv_pct(100)); + lv_obj_set_style_align(disc, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_radius(disc, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_set_style_bg_opa(disc, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_bg_grad(disc, grad, LV_PART_MAIN); + lv_obj_remove_flag(disc, LV_OBJ_FLAG_SCROLLABLE); + return disc; + } + + // Builds the colour wheel that replaces the hue and saturation sliders, and returns its container. + lv_obj_t *create_wheel_(lv_obj_t *parent) { + auto *container = create_box(parent); + this->wheel_ = create_disc(container, &wheel_hue_grad); + // Not clickable, so touches fall through to the wheel underneath. + auto *overlay = create_disc(this->wheel_, &wheel_white_grad); + lv_obj_remove_flag(overlay, LV_OBJ_FLAG_CLICKABLE); + + this->wheel_handle_ = lv_obj_create(this->wheel_); + lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE); + lv_obj_set_style_align(this->wheel_handle_, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_radius(this->wheel_handle_, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_set_style_bg_opa(this->wheel_handle_, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(this->wheel_handle_, 2, LV_PART_MAIN); + lv_obj_set_style_border_color(this->wheel_handle_, lv_color_white(), LV_PART_MAIN); + lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE); + + // PRESSED as well, so a tap takes effect without the finger moving. + lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSED, this); + lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSING, this); + // The handle can only be placed once the wheel has been laid out. + lv_obj_add_event_cb(this->wheel_, wheel_size_cb, LV_EVENT_SIZE_CHANGED, this); + return container; + } +#endif + + void add_value_label_(SliderIndex index, lv_color_t color) { + auto *slider = this->sliders_[index]; + lv_obj_add_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + auto *label = lv_label_create(slider); + this->values_[index] = label; + lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_text_color(label, color, LV_PART_MAIN); + } + + // Leaves room at the ends of a vertical slider for the overhanging knob: half a line of text. + static void update_knob_clearance(lv_obj_t *container) { + const auto *font = lv_obj_get_style_text_font(container, LV_PART_MAIN); + auto clearance = static_cast(lv_font_get_line_height(font)) / 2; + if (clearance == lv_obj_get_style_pad_bottom(container, LV_PART_MAIN)) + return; + lv_obj_set_style_pad_bottom(container, clearance, LV_PART_MAIN); + lv_obj_set_style_pad_row(container, clearance, LV_PART_MAIN); + } + + static void knob_clearance_cb(lv_event_t *event) { update_knob_clearance(lv_event_get_current_target_obj(event)); } + + static LvColorPickerType *from_event(lv_event_t *event) { + return static_cast(lv_event_get_user_data(event)); + } + static void hsv_changed_cb(lv_event_t *event) { from_event(event)->update_hsv_(); } + static void rgb_changed_cb(lv_event_t *event) { from_event(event)->update_rgb_(); } +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + static void wheel_touch_cb(lv_event_t *event) { from_event(event)->update_from_wheel_touch_(); } + static void wheel_size_cb(lv_event_t *event) { from_event(event)->update_wheel_handle_(); } +#endif + static void size_changed_cb(lv_event_t *event) { from_event(event)->update_bar_thickness_(); } + + // Gives the horizontal bars the thickness of the vertical ones, half a hue column, so every + // knob is the same size. A knob is sized from the thickness of its bar. + void update_bar_thickness_() { + auto thickness = lv_obj_get_content_width(this->obj) * HUE_WIDTH_PCT / 200; + for (auto index : {SLIDER_SATURATION, SLIDER_BRIGHTNESS}) { + if (this->has_(index) && lv_obj_get_style_height(this->sliders_[index], LV_PART_MAIN) != thickness) + lv_obj_set_style_height(this->sliders_[index], thickness, LV_PART_MAIN); + } + } + + // LVGL also counts this size when working out the scroll area, so a widget given a fixed + // width must not claim it, or it would scroll whenever it is smaller. + static void self_size_cb(lv_event_t *event) { + auto *obj = lv_event_get_current_target_obj(event); + if (lv_obj_get_style_width(obj, LV_PART_MAIN) != LV_SIZE_CONTENT) + return; + const auto *font = lv_obj_get_style_text_font(obj, LV_PART_MAIN); + auto self_size = static_cast(lv_font_get_line_height(font)) * SELF_SIZE_FONT_MULTIPLE; + auto *point = static_cast(lv_event_get_param(event)); + point->x = self_size; + point->y = self_size; + } + + void update_text_() const { + // "#" + 3x "%02X" + null + char buf[8]; + snprintf(buf, sizeof(buf), "#%02X%02X%02X", static_cast(this->state_.r), + static_cast(this->state_.g), static_cast(this->state_.b)); + lv_label_set_text(this->color_text_, buf); + } + + void update_hue_bar_(SliderIndex index, lv_grad_dsc_t *grad, lv_color_t color) { + if (!this->has_(index)) + return; + grad->stops[1].color = color; + lv_obj_invalidate(this->sliders_[index]); + } + + void update_values_() const { + for (size_t index = 0; index < SLIDER_COUNT; index++) { + if (this->values_[index] == nullptr) + continue; + // up to 3 digits + null + char buf[4]; + snprintf(buf, sizeof(buf), "%d", static_cast(lv_slider_get_value(this->sliders_[index]))); + lv_label_set_text(this->values_[index], buf); + } + } + + void tint_knob_(SliderIndex index, lv_color_t color) const { + if (this->has_(index)) + lv_obj_set_style_bg_color(this->sliders_[index], color, LV_PART_KNOB); + } + + // Tints each knob with the colour of its bar at the current value. The brightness knob + // shows the selected colour instead, since its bar leaves out saturation. + void update_knobs_(lv_color_t full_hue) const { + if (!this->tint_knobs_) + return; + this->tint_knob_(SLIDER_HUE, full_hue); + this->tint_knob_(SLIDER_SATURATION, lv_color_hsv_to_rgb(this->hue_, this->saturation_, 100)); + this->tint_knob_(SLIDER_BRIGHTNESS, this->state_); + this->tint_knob_(SLIDER_RED, lv_color_make(255, 255 - this->state_.r, 255 - this->state_.r)); + this->tint_knob_(SLIDER_GREEN, lv_color_make(255 - this->state_.g, 255, 255 - this->state_.g)); + this->tint_knob_(SLIDER_BLUE, lv_color_make(255 - this->state_.b, 255 - this->state_.b, 255)); + } + +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // Returns the wheel's radius, which is zero until it has been laid out. + double wheel_radius_(lv_area_t *area) const { + lv_obj_get_coords(this->wheel_, area); + return static_cast(area->x2 - area->x1) / 2.0; + } + + void update_wheel_handle_() { + if (this->wheel_handle_ == nullptr) + return; + lv_area_t area; + auto radius = this->wheel_radius_(&area); + if (radius <= 0) + return; + auto angle_rad = static_cast(this->hue_) * (std::numbers::pi / 180.0); + auto distance = (static_cast(this->saturation_) / 100.0) * radius; + lv_obj_set_style_translate_x(this->wheel_handle_, static_cast(std::lround(distance * std::cos(angle_rad))), + LV_PART_MAIN); + lv_obj_set_style_translate_y(this->wheel_handle_, static_cast(std::lround(distance * std::sin(angle_rad))), + LV_PART_MAIN); + } + + // The angle from the centre gives the hue, the distance the saturation. Brightness is unchanged. + void update_from_wheel_touch_() { + lv_indev_t *indev = lv_indev_active(); + if (indev == nullptr) + return; + lv_point_t point; + lv_indev_get_point(indev, &point); + lv_area_t area; + auto radius = this->wheel_radius_(&area); + if (radius <= 0) + return; + auto dx = point.x - (area.x1 + area.x2) / 2.0; + auto dy = point.y - (area.y1 + area.y2) / 2.0; + auto angle = std::atan2(dy, dx) * (180.0 / std::numbers::pi); + if (angle < 0) + angle += 360.0; + this->hue_ = static_cast(std::lround(angle)) % 360; + auto distance = std::sqrt(dx * dx + dy * dy); + this->saturation_ = static_cast(std::lround(std::min(100.0, distance * 100.0 / radius))); + this->brightness_ = static_cast(this->slider_value_(SLIDER_BRIGHTNESS, this->brightness_)); + this->apply_hsv_(); + // Only sliders send this themselves; without it on_value would not fire for the wheel. + lv_obj_send_event(this->wheel_, LV_EVENT_VALUE_CHANGED, nullptr); + } +#endif + + // Lets events from the internal sliders reach the widget, where the triggers listen. Every + // object in between needs the flag; the widget itself does not, so events stop there. + static void bubble_events(lv_obj_t *obj) { + for (uint32_t i = 0; i < lv_obj_get_child_count(obj); i++) { + auto *child = lv_obj_get_child(obj, i); + lv_obj_add_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE); + bubble_events(child); + } + } + + void refresh_() { + lv_obj_set_style_bg_color(this->color_indicator_, this->state_, LV_PART_MAIN); + auto full_hue = lv_color_hsv_to_rgb(this->hue_, 100, 100); + this->update_hue_bar_(SLIDER_SATURATION, &this->saturation_bar_, full_hue); + this->update_hue_bar_(SLIDER_BRIGHTNESS, &this->brightness_bar_, full_hue); + this->update_text_(); + this->update_values_(); + this->update_knobs_(full_hue); +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + this->update_wheel_handle_(); +#endif + } + + // Sets the colour from the hue, saturation and brightness, and moves the RGB sliders to match. + void apply_hsv_() { + lv_color32_t c32 = + lv_color_to_32(lv_color_hsv_to_rgb(this->hue_, this->saturation_, this->brightness_), LV_OPA_COVER); + this->state_ = Color(c32.red, c32.green, c32.blue); + this->set_slider_value_(SLIDER_RED, c32.red); + this->set_slider_value_(SLIDER_GREEN, c32.green); + this->set_slider_value_(SLIDER_BLUE, c32.blue); + this->refresh_(); + } + + void update_hsv_() { + this->hue_ = static_cast(this->slider_value_(SLIDER_HUE, this->hue_)); + this->saturation_ = static_cast(this->slider_value_(SLIDER_SATURATION, this->saturation_)); + this->brightness_ = static_cast(this->slider_value_(SLIDER_BRIGHTNESS, this->brightness_)); + this->apply_hsv_(); + } + + void update_rgb_() { + this->state_ = Color(static_cast(this->slider_value_(SLIDER_RED, this->state_.r)), + static_cast(this->slider_value_(SLIDER_GREEN, this->state_.g)), + static_cast(this->slider_value_(SLIDER_BLUE, this->state_.b))); + auto hsv = lv_color_rgb_to_hsv(this->state_.r, this->state_.g, this->state_.b); + this->hue_ = hsv.h; + this->saturation_ = hsv.s; + this->brightness_ = hsv.v; + // A grey, white or black colour leaves a picker stuck there if nothing it has can move the + // saturation, or the brightness, away from zero. + bool stuck = false; + if (this->saturation_ == 0 && !this->can_change_saturation_()) { + this->saturation_ = 100; + stuck = true; + } + if (this->brightness_ == 0 && !this->can_change_brightness_()) { + this->brightness_ = 100; + stuck = true; + } + this->set_slider_value_(SLIDER_HUE, this->hue_); + this->set_slider_value_(SLIDER_SATURATION, this->saturation_); + this->set_slider_value_(SLIDER_BRIGHTNESS, this->brightness_); + if (stuck) { + this->apply_hsv_(); + } else { + this->refresh_(); + } + } + + void update_color_() { + this->set_slider_value_(SLIDER_RED, this->state_.r); + this->set_slider_value_(SLIDER_GREEN, this->state_.g); + this->set_slider_value_(SLIDER_BLUE, this->state_.b); + this->update_rgb_(); + lv_obj_invalidate(this->obj); + } + + // Shared by every colour picker; set up once by init_shared(). + inline static bool shared_ready{false}; + inline static lv_grad_dsc_t hue_bar{}; + inline static lv_grad_dsc_t red_bar{}; + inline static lv_grad_dsc_t green_bar{}; + inline static lv_grad_dsc_t blue_bar{}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + inline static lv_grad_dsc_t wheel_hue_grad{}; + inline static lv_grad_dsc_t wheel_white_grad{}; +#endif + inline static lv_style_t plain_box{}; + inline static lv_style_t slider_vert{}; + inline static lv_style_t slider_vert_knob{}; + inline static lv_style_t slider_horz{}; + inline static lv_style_t slider_horz_knob{}; + + // Per widget, since their far end follows the chosen hue. + lv_grad_dsc_t saturation_bar_{}; + lv_grad_dsc_t brightness_bar_{}; + Color state_{0x80, 0x80, 0x80}; + std::array sliders_{}; + std::array values_{}; + lv_obj_t *color_indicator_{}; + lv_obj_t *color_text_{}; + // Kept alongside state_ so a slider left out of the widget still has a value. + uint16_t hue_{0}; + uint8_t saturation_{0}; + uint8_t brightness_{50}; + const uint8_t sliders_mask_; + const bool use_wheel_; + bool tint_knobs_{true}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // Null unless use_wheel_ is set. + lv_obj_t *wheel_{}; + lv_obj_t *wheel_handle_{}; +#endif +}; + +} // namespace esphome::lvgl + +#endif // USE_LVGL_COLOR_PICKER diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 2858b41f71..69e15bdeca 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -143,8 +143,8 @@ def get_theme_styles() -> dict[str, MockObj]: return _get_data(KEY_THEME_STYLES, {}) -def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, MockObj]]]: - """Get the map of widget type names to the list of (style variable, part/state name)""" +def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, str, str]]]: + """Get the map of widget type names to the list of (style variable, part, state)""" return _get_data(KEY_WIDGET_THEME_STYLES, {}) @@ -723,6 +723,7 @@ CONF_CHANGE_RATE = "change_rate" CONF_CLICKABLE = "clickable" CONF_CLOSE_BUTTON = "close_button" CONF_COLOR_END = "color_end" +CONF_COLOR_PICKER = "color_picker" CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" CONF_CONTROL = "control" diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index 8db183fabe..df62e30f8a 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -13,6 +13,7 @@ from esphome.core import ID from esphome.cpp_generator import MockObj from .defines import ( + CONF_COLOR_PICKER, CONF_END_ANGLE, CONF_GRADIENTS, CONF_OPA, @@ -22,6 +23,7 @@ from .defines import ( add_define, add_lv_use, add_warning, + get_lv_uses, ) from .lv_validation import ( lv_angle_degrees, @@ -164,7 +166,7 @@ GRADIENT_SCHEMA = cv.ensure_list( async def gradients_to_code(config): add_lv_use("gradient") - max_stops = 2 + max_stops = 7 if CONF_COLOR_PICKER in get_lv_uses() else 2 if any(CONF_DITHER in x for x in config.get(CONF_GRADIENTS, ())): add_warning( "The 'dither' option for gradients is not supported by LVGL 9.x and will be ignored" diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 1fef37e2ee..8231cd3b84 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -12,7 +12,7 @@ from .defines import ( STATES, LValidator, add_lv_use, - get_part_state_selector, + add_warning, get_styles_used, get_theme_styles, get_theme_update_requests, @@ -28,7 +28,7 @@ from .schemas import ( theme_update_schema, ) from .types import ObjUpdateAction, lv_style_t -from .widgets import collect_parts +from .widgets import collect_parts, misplaced_obj_properties def has_style_props(config) -> bool: @@ -77,9 +77,20 @@ class LVStyle: return cls.named_styles.setdefault(id_name, LVStyle(id_name)) +def _warn_misplaced_obj_properties(config: dict, owner: str) -> None: + """Warn about widget properties in a style or theme, where they have no effect.""" + for where, prop in misplaced_obj_properties(config, include_top=True): + place = f"{owner} under '{where}'" if where else owner + add_warning( + f"'{prop}' applies to a widget, not to a style, so setting it in {place} has " + f"no effect. Set it on each widget instead." + ) + + async def styles_to_code(config): """Convert styles to C__ code.""" for style in config.get(CONF_STYLE_DEFINITIONS, ()): + _warn_misplaced_obj_properties(style, f"style '{style[CONF_ID].id}'") await create_style(style[CONF_ID].id, style) @@ -109,8 +120,8 @@ def _get_theme_style_name(w_name: str, part: str, state: str) -> str: return f"_lv_theme_style_{w_name}_{part}_{state}" -def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]: - """Return a list of (style variable, part/state name) for all theme styles used by the given widget type.""" +def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, str, str]]: + """Return a list of (style variable, part, state) for all theme styles used by the given widget type.""" widget_styles = get_widget_theme_style_data() if w_name in widget_styles: return widget_styles[w_name] @@ -120,9 +131,7 @@ def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]: for state in STATES + (CONF_DEFAULT,): style_name = _get_theme_style_name(w_name, part, state) if style_name in theme_styles: - style_list.append( - (theme_styles[style_name], get_part_state_selector(part, state)) - ) + style_list.append((theme_styles[style_name], part, state)) widget_styles[w_name] = style_list return style_list @@ -137,6 +146,9 @@ async def theme_to_code(config): :return: """ theme = config.get(CONF_THEME) or {} + for w_name, w_theme in theme.items(): + if isinstance(w_theme, dict): + _warn_misplaced_obj_properties(w_theme, f"the theme for '{w_name}'") requests = get_theme_update_requests() widget_names = [ w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index b48123b902..210314103e 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,5 @@ -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterator +from contextlib import ExitStack import sys from typing import Any @@ -45,6 +46,7 @@ from ..defines import ( STATES, LValidator, add_lv_use, + add_warning, get_options, get_part_state_selector, get_styles_used, @@ -54,6 +56,7 @@ from ..defines import ( ) from ..lv_validation import lv_int from ..lvcode import ( + LocalVariable, add_line_marks, lv, lv_add, @@ -182,7 +185,7 @@ class WidgetType: wid = config[CONF_ID] add_line_marks(wid) if self.is_compound(): - var = cg.new_Pvariable(wid) + var = cg.new_Pvariable(wid, *await self.get_ctor_args(config)) lv_add(var.set_obj(creator)) await self.on_create(var.obj, config) else: @@ -229,6 +232,37 @@ class WidgetType: :param config: Its configuration """ + async def get_ctor_args(self, config: dict) -> list: + """ + Get extra arguments for a compound widget's constructor. Anything the widget needs + before it builds its parts belongs here, since a setter would be called too late. + :param config: Its configuration + :return: + """ + return [] + + def obj_targets(self, w: "Widget", prop: str) -> list["Widget"]: + """ + Get the widgets one object property, as opposed to a style, applies to. A widget + built out of other widgets can point a property at the ones it really concerns, such + as sending a touch margin to the parts that are actually pressed. + :param w: The widget being configured + :param prop: The property name, one of OBJ_PROPERTIES + :return: + """ + return [w] + + def part_targets(self, w: "Widget", part: str) -> list[tuple["Widget", str]]: + """ + Get the widgets a part's styles should be applied to, each paired with the part to + use on it. A widget built out of other widgets can point a part at those, so that + styling it works the same as it does for a plain widget. + :param w: The widget being styled + :param part: The configured part name + :return: + """ + return [(w, part)] + def get_uses(self) -> tuple: """ Get a list of other widgets used by this one @@ -270,8 +304,9 @@ def apply_theme_styles(w: "Widget") -> None: """Apply the current theme's styles for this widget's type""" from ..styles import get_widget_theme_styles - for style, lv_state in get_widget_theme_styles(w.type.name): - w.add_style(style, lv_state) + for style, part, state in get_widget_theme_styles(w.type.name): + for target, target_part in w.type.part_targets(w, part): + target.add_style(style, get_part_state_selector(target_part, state)) def apply_debug_outline(w: "Widget") -> None: @@ -539,6 +574,38 @@ def collect_states(config): return states +def misplaced_obj_properties( + config: dict, include_top: bool = False +) -> Iterator[tuple[str, str]]: + """ + Find object properties set where LVGL has no way to apply them. They belong to a widget + as a whole, taking neither a part nor a state, so anywhere else they are quietly dropped. + :param config: A widget, style or theme configuration + :param include_top: Also report the top level, as for a style, which never holds them + :return: Each place one was found, as a part/state path ("" for the top level), paired + with the property name + """ + from ..schemas import OBJ_PROPERTIES + + def scan(sub: Any, where: str) -> Iterator[tuple[str, str]]: + if isinstance(sub, dict): + for prop in sorted(OBJ_PROPERTIES.intersection(sub)): + yield where, prop + + if include_top: + yield from scan(config, "") + for state in STATES: + if state in config: + yield from scan(config[state], state) + for part in PARTS: + if part not in config: + continue + yield from scan(config[part], part) + for state in STATES: + if state in config[part]: + yield from scan(config[part][state], f"{part}/{state}") + + def collect_parts(config): """ Collect properties and states for all widget parts @@ -610,6 +677,13 @@ def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None: w.set_style("flex_track_place", literal(track)) +def _widget_name(w: Widget) -> str: + """The id a widget was given, for naming it in a warning.""" + if isinstance(w.config, dict) and (wid := w.config.get(CONF_ID)) is not None: + return str(wid) + return str(w.var) + + async def set_obj_properties(w: Widget, config): """Generate a list of C++ statements to apply properties to an lv_obj_t""" @@ -628,19 +702,42 @@ async def set_obj_properties(w: Widget, config): else: base_name = None _set_layout_options(w, layout, base_name) + for where, prop in misplaced_obj_properties(config): + add_warning( + f"'{prop}' applies to a widget as a whole, not to one part or state of it, so " + f"setting it under '{where}' of widget '{_widget_name(w)}' has no effect. Set it " + f"on the widget itself instead." + ) parts = collect_parts(config) for part, states in parts.items(): + targets = w.type.part_targets(w, part) for state, props in states.items(): - lv_state = get_part_state_selector(part, state) - for style_id in props.get(CONF_STYLES, ()): - w.add_style(style_id, lv_state) - for prop, value in { - k: v for k, v in props.items() if k in ALL_STYLES - }.items(): - if isinstance(ALL_STYLES[prop], LValidator): - value = await ALL_STYLES[prop].process(value) - prop_r = remap_property(prop) - w.set_style(prop_r, value, lv_state) + style_ids = props.get(CONF_STYLES, ()) + # Each value is worked out once and then given to every target. A widget built + # out of others has one target per part, and a lambda is written out in full + # wherever it appears, so with more than one target it goes into a variable + # rather than being repeated. The blocks holding those variables stay open + # until every target has been dealt with. + with ExitStack() as stack: + values = [] + for prop, value in { + k: v for k, v in props.items() if k in ALL_STYLES + }.items(): + validator = ALL_STYLES[prop] + if isinstance(validator, LValidator): + is_lambda = isinstance(value, cv.Lambda) + value = await validator.process(value) + if is_lambda and len(targets) > 1: + value = stack.enter_context( + LocalVariable(prop, validator.rtype, value, modifier="") + ) + values.append((remap_property(prop), value)) + for target, target_part in targets: + lv_state = get_part_state_selector(target_part, state) + for style_id in style_ids: + target.add_style(style_id, lv_state) + for prop_r, value in values: + target.set_style(prop_r, value, lv_state) if group := config.get(CONF_GROUP): group = await cg.get_variable(group) lv.group_add_obj(group, w.obj) @@ -663,7 +760,8 @@ async def set_obj_properties(w: Widget, config): w.set_state(state, value) for property in OBJ_PROPERTIES: - await w.set_property(property, config, lv_name="obj") + for target in w.type.obj_targets(w, property): + await target.set_property(property, config, lv_name="obj") async def add_widgets(parent: Widget, config: dict): diff --git a/esphome/components/lvgl/widgets/color_picker.py b/esphome/components/lvgl/widgets/color_picker.py new file mode 100644 index 0000000000..4f501ba0ee --- /dev/null +++ b/esphome/components/lvgl/widgets/color_picker.py @@ -0,0 +1,240 @@ +from typing import Any + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_COLOR, CONF_HEIGHT, CONF_ITEMS, CONF_WIDTH +from esphome.core import CORE + +from ..defines import ( + CONF_COLOR_PICKER, + CONF_EXT_CLICK_AREA, + CONF_KNOB, + CONF_MAIN, + CONF_STYLES, + CONF_THEME, + DOMAIN, + add_define, + add_lv_use, + literal, +) +from ..lv_validation import lv_color, size +from ..lvcode import lv_add +from ..types import LvCompound, LvType +from . import Widget, WidgetType +from .label import CONF_LABEL +from .lv_bar import CONF_BAR +from .slider import CONF_SLIDER, slider_spec + +# esphome::Color, not lv_color_t: it carries the same value but exposes the components as +# `x.r`/`x.g`/`x.b`, and converts to lv_color_t on its own where LVGL needs one. +Color = cg.esphome_ns.class_("Color") + +lv_color_picker_t = LvType( + "LvColorPickerType", + parents=(LvCompound,), + largs=[(Color, "x")], + lvalue=lambda w: w.var.get_color(), + has_on_value=True, +) + +# Makes `lvgl.widget.update` with a `color:` report the change through `on_value`, the same +# as setting the value of any other widget does. +lv_color_picker_t.value_property = CONF_COLOR + +CONF_SLIDERS = "sliders" +CONF_WHEEL = "wheel" + +CONF_BG_COLOR = "bg_color" + +# The sliders the widget can be built from. The order must match +# LvColorPickerType::SliderIndex, since it decides which bit of the mask each one gets. +SLIDER_NAMES = ( + "hue", + "saturation", + "brightness", + "red", + "green", + "blue", +) + +# Shorthand for the two sets of sliders that are usually wanted together. +SLIDER_GROUPS = { + "hsv": ("hue", "saturation", "brightness"), + "hsb": ("hue", "saturation", "brightness"), + "rgb": ("red", "green", "blue"), +} + +# The wheel draws hue and saturation itself, and the layout drops the RGB sliders to make +# room for it, leaving brightness as the only slider that still fits. +WHEEL_EXCLUDED_SLIDERS = ("hue", "saturation", "red", "green", "blue") + + +def validate_sliders(value: Any) -> list[str]: + """Expand any group names and drop repeats, keeping the widget's own slider order.""" + value = cv.ensure_list(cv.one_of(*SLIDER_NAMES, *SLIDER_GROUPS, lower=True))(value) + chosen = {name for item in value for name in SLIDER_GROUPS.get(item, (item,))} + if not chosen: + raise cv.Invalid("At least one slider is required") + return [name for name in SLIDER_NAMES if name in chosen] + + +COLOR_PICKER_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_COLOR): lv_color, + } +) + +COLOR_PICKER_SCHEMA = COLOR_PICKER_MODIFY_SCHEMA.extend( + { + cv.Optional(CONF_WIDTH, default="SIZE_CONTENT"): size, + cv.Optional(CONF_HEIGHT): cv.invalid("Height will be set to the same as width"), + cv.Optional(CONF_SLIDERS, default=list(SLIDER_NAMES)): validate_sliders, + cv.Optional(CONF_WHEEL, default=False): cv.boolean, + } +) + + +# Starting colour for a picker with no way to change saturation, which would otherwise be stuck +# at the grey it starts with. Saturation changes with its own slider, the wheel, or any of the +# red, green and blue sliders, so only these two sliders on their own leave it fixed. +SATURATED_DEFAULT_COLOR = 0xFF0000 +HUE_AND_BRIGHTNESS = {"hue", "brightness"} + + +def _knob_color_is_set(knob: dict) -> bool: + """Whether a knob is given a background colour, directly or through named styles.""" + return CONF_BG_COLOR in knob or bool(knob.get(CONF_STYLES)) + + +def _theme_sets_knob_color() -> bool: + """Whether any LVGL theme gives the color picker's knobs a background colour.""" + configs = CORE.config.get(DOMAIN) or [] + if isinstance(configs, dict): + configs = [configs] + return any( + _knob_color_is_set( + ((config.get(CONF_THEME) or {}).get(CONF_COLOR_PICKER) or {}).get( + CONF_KNOB, {} + ) + ) + for config in configs + ) + + +def _sliders(config: dict) -> list[str]: + """Get the sliders a widget was created with, in the widget's own order.""" + return config.get(CONF_SLIDERS) or list(SLIDER_NAMES) + + +class ColorPickerType(WidgetType): + def __init__(self) -> None: + super().__init__( + CONF_COLOR_PICKER, + lv_color_picker_t, + parts=(CONF_MAIN, CONF_ITEMS, CONF_KNOB), + schema=COLOR_PICKER_SCHEMA, + modify_schema=COLOR_PICKER_MODIFY_SCHEMA, + lv_name="obj", + ) + + def validate(self, value: Any) -> Any: + add_lv_use(CONF_COLOR_PICKER) + value = dict(value) + if cv.boolean(value.get(CONF_WHEEL, False)): + # The wheel is a conical hue gradient under a radial white-to-transparent one; + # LVGL's software renderer only draws either kind when this is enabled. + add_define("LV_USE_DRAW_SW_COMPLEX_GRADIENTS") + if CONF_SLIDERS in value: + sliders = validate_sliders(value[CONF_SLIDERS]) + if any(name in sliders for name in WHEEL_EXCLUDED_SLIDERS): + raise cv.Invalid( + "the color wheel already shows hue and saturation, and replaces " + "the red, green and blue sliders with it; remove them from " + "'sliders' or set 'wheel: false'" + ) + else: + # Otherwise the schema default would build every slider including these. + value[CONF_SLIDERS] = [ + name for name in SLIDER_NAMES if name not in WHEEL_EXCLUDED_SLIDERS + ] + return super().validate(value) + + async def get_ctor_args(self, config: dict) -> list: + # The layout is worked out from the sliders, so they are needed before the widget is + # built rather than set afterwards. + return [ + literal( + " | ".join( + f"{lv_color_picker_t}::SLIDER_FLAG_{name.upper()}" + for name in _sliders(config) + ) + ), + config[CONF_WHEEL], + ] + + @staticmethod + def _sliders_of(w: Widget) -> list[Widget]: + """Wrap each slider the widget was created with, so it can be configured directly.""" + return [ + Widget( + w.var.get_slider( + literal(f"{lv_color_picker_t}::SLIDER_{name.upper()}") + ), + slider_spec, + ) + for name in _sliders(w.config or {}) + ] + + def obj_targets(self, w: Widget, prop: str) -> list[Widget]: + # Nothing is ever pressed on its own object, so the touch margin belongs on the + # sliders. Everything else, scrolling in particular, is a matter for the container + # holding them and so stays where it is. + if prop == CONF_EXT_CLICK_AREA: + return self._sliders_of(w) + return [w] + + def part_targets(self, w: Widget, part: str) -> list[tuple[Widget, str]]: + # The widget is made of sliders, so `items` and `knob` are the main and knob parts of + # each of those. Its own object is a plain container with neither. Only the sliders + # the widget was created with exist, so only those are styled. + if part == CONF_MAIN: + return [(w, part)] + target_part = CONF_MAIN if part == CONF_ITEMS else part + return [(slider, target_part) for slider in self._sliders_of(w)] + + async def to_code(self, w: Widget, config: dict) -> None: + # Only the configuration that creates the widget has `sliders`; an update does not. + creating = CONF_SLIDERS in config + if creating and config[CONF_WHEEL]: + # Without a wheel anywhere, its code and gradients are left out of the build. + cg.add_define("USE_LVGL_COLOR_PICKER_WHEEL") + color = config.get(CONF_COLOR) + if ( + color is None + and creating + and not config[CONF_WHEEL] + and set(config[CONF_SLIDERS]) <= HUE_AND_BRIGHTNESS + ): + color = SATURATED_DEFAULT_COLOR + # A hex colour validates to a plain int, so black must not be mistaken for absent. + if color is not None: + lv_add(w.var.set_color(await lv_color.process(color))) + # Each knob is normally tinted with the colour its own slider shows. That is applied + # as a local style, which outranks any style the knob has been given, so a background + # colour configured for the knob, here or in the theme, would never show; tinting + # gives way to it instead. + if _knob_color_is_set(config.get(CONF_KNOB, {})) or ( + creating and _theme_sets_knob_color() + ): + lv_add(w.var.set_tint_knobs(False)) + # The widget is square, so the height simply follows the configured width. + # SIZE_CONTENT works too: the widget reports a size based on its text font. + # Width is required when creating the widget but absent when updating one. + if (width := config.get(CONF_WIDTH)) is not None: + w.set_style(CONF_HEIGHT, await size.process(width), 0) + + def get_uses(self) -> tuple[str, ...]: + return ("flex", CONF_SLIDER, CONF_BAR, CONF_LABEL) + + +color_picker_spec = ColorPickerType() diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 28abdb2218..00e53765c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -111,6 +111,8 @@ #define USE_LVGL_CANVAS #define USE_LVGL_CHART #define USE_LVGL_CHECKBOX +#define USE_LVGL_COLOR_PICKER +#define USE_LVGL_COLOR_PICKER_WHEEL #define USE_LVGL_DROPDOWN #define USE_LVGL_FONT #define USE_LVGL_GRADIENT @@ -718,3 +720,12 @@ #define ESPHOME_ENTITY_VALVE_COUNT 1 #define ESPHOME_ENTITY_WATER_HEATER_COUNT 1 #define ESPHOME_MAX_USB_CDC_INSTANCES 1 + +// Keep clang-tidy happy. + +// The real value comes from lvgl/gradient.py, which works it out from the configuration. The +// largest it uses is set here so that static analysis sees every gradient in the component, +// but only where a generated lv_conf.h has not already given it a value. +#ifndef LV_GRADIENT_MAX_STOPS +#define LV_GRADIENT_MAX_STOPS 7 +#endif diff --git a/tests/component_tests/lvgl/config/color_picker.yaml b/tests/component_tests/lvgl/config/color_picker.yaml new file mode 100644 index 0000000000..edff494234 --- /dev/null +++ b/tests/component_tests/lvgl/config/color_picker.yaml @@ -0,0 +1,133 @@ +esphome: + name: test-color-picker + +esp32: + board: lolin_c3_mini + +logger: + +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 + +color: + - id: picker_colour + hex: "3366CC" + +lvgl: + style_definitions: + - id: picker_style + bg_opa: cover + + widgets: + # Sized from the text font + - color_picker: + id: picker_content + width: SIZE_CONTENT + + # Fixed width, colour given as a hex literal + - color_picker: + id: picker_fixed + width: 120 + color: 0xFF8800 + + # Styling the sliders the widget is built from + - color_picker: + id: picker_styled + width: 100 + items: + bg_opa: 80% + pressed: + border_width: 2 + knob: + bg_color: 0x112233 + radius: 4 + styles: [picker_style] + # One value for six sliders: it must not be generated once per slider. The body + # is more than an expression so that it stays a lambda rather than being folded + # into a constant. + border_width: !lambda |- + int width = 3; + return width; + pressed: + styles: [picker_style] + # Belongs to the widget as a whole, so it is reported and ignored here + ext_click_area: 8 + + # Only the red, green and blue sliders, with a margin making them easier to press. + # The scrolling options belong to the widget's own object, not to the sliders. + - color_picker: + id: picker_rgb + width: 100 + sliders: rgb + ext_click_area: 12 + scrollbar_mode: "OFF" + + # Black must not be mistaken for no colour at all + - color_picker: + id: picker_black + width: 100 + color: 0x000000 + + # The knob background arrives through a named style rather than directly + - color_picker: + id: picker_knob_style + width: 100 + knob: + styles: [picker_style] + + # Only the hue, saturation and brightness sliders, styled + - color_picker: + id: picker_hsv + width: 100 + sliders: [hsv] + knob: + radius: 2 + + # A single slider, for picking a hue with the brightness controlled elsewhere + - color_picker: + id: picker_hue + width: 100 + sliders: hue + + # A colour wheel in place of the hue and saturation sliders + - color_picker: + id: picker_wheel + width: 100 + wheel: true + + # Colour given as the id of a colour defined above + - color_picker: + id: picker_colour_id + width: 50% + color: picker_colour + on_value: + - logger.log: + format: "picked %d,%d,%d" + args: [x.r, x.g, x.b] + on_release: + - logger.log: released + on_update: + - logger.log: updated + +script: + - id: update_picker + then: + - lvgl.color_picker.update: + id: picker_fixed + color: 0x00FF00 + - lvgl.color_picker.update: + id: picker_hue + color: !lambda return lv_color_hex(0x123456); diff --git a/tests/component_tests/lvgl/config/color_picker_theme.yaml b/tests/component_tests/lvgl/config/color_picker_theme.yaml new file mode 100644 index 0000000000..47ae267cae --- /dev/null +++ b/tests/component_tests/lvgl/config/color_picker_theme.yaml @@ -0,0 +1,48 @@ +esphome: + name: test-color-picker + +esp32: + board: lolin_c3_mini + +logger: + +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: + style_definitions: + # A widget property in a style is dropped, so it is reported + - id: clickable_style + ext_click_area: 10 + + theme: + # A themed knob colour must not be covered by the knob tinting + color_picker: + knob: + bg_color: 0x112233 + # Widget properties in a theme are dropped too, at any level + button: + scrollbar_mode: "OFF" + pressed: + ext_click_area: 4 + + widgets: + - color_picker: + id: themed_picker + width: 100 + - button: + id: themed_button + styles: clickable_style diff --git a/tests/component_tests/lvgl/config/named_styles.yaml b/tests/component_tests/lvgl/config/named_styles.yaml new file mode 100644 index 0000000000..2adc16ddf4 --- /dev/null +++ b/tests/component_tests/lvgl/config/named_styles.yaml @@ -0,0 +1,47 @@ +esphome: + name: test-named-styles + +esp32: + board: lolin_c3_mini + +logger: + +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: + style_definitions: + - id: chunky + bg_opa: cover + border_width: 2 + + widgets: + # A plain widget, so the selector each named style is added with is the one LVGL is + # given directly, with no compound widget in the way. + - slider: + id: plain_slider + width: 100 + # No part and no state: the selector is a bare zero. + styles: [chunky] + knob: + # A part on its own. + styles: [chunky] + pressed: + # A part combined with a state, which has to survive the cast. + styles: [chunky] + pressed: + # A state on its own. + styles: [chunky] diff --git a/tests/component_tests/lvgl/test_color_picker.py b/tests/component_tests/lvgl/test_color_picker.py new file mode 100644 index 0000000000..1d094a62dc --- /dev/null +++ b/tests/component_tests/lvgl/test_color_picker.py @@ -0,0 +1,628 @@ +"""Tests for the LVGL color picker widget schema and code generation.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest +from voluptuous import Schema + +from esphome.components.lvgl.schemas import container_schema +from esphome.components.lvgl.widgets import _build_update_schema +from esphome.components.lvgl.widgets.color_picker import color_picker_spec +from esphome.config_validation import Invalid +from esphome.const import CONF_WIDTH + + +@pytest.fixture() +def update_schema() -> Schema: + """The schema behind `lvgl.color_picker.update`.""" + return _build_update_schema(color_picker_spec) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class TestColorPickerValidation: + """Test the widget's own schema options.""" + + @pytest.fixture() + def schema(self) -> Callable[[dict], dict]: + return container_schema(color_picker_spec) + + @pytest.mark.parametrize("width", [100, "50%", "SIZE_CONTENT"]) + def test_width_accepted( + self, schema: Callable[[dict], dict], width: int | str + ) -> None: + assert schema({"width": width}) is not None + + def test_width_defaults_to_size_content( + self, schema: Callable[[dict], dict] + ) -> None: + assert schema({})[CONF_WIDTH] == "LV_SIZE_CONTENT" + + def test_height_is_rejected(self, schema: Callable[[dict], dict]) -> None: + """The widget is square, so a height of its own makes no sense.""" + with pytest.raises(Invalid, match="Height will be set to the same as width"): + schema({"width": 100, "height": 100}) + + def test_color_is_optional(self, schema: Callable[[dict], dict]) -> None: + assert "color" not in schema({"width": 100}) + + def test_on_value_is_accepted(self, schema: Callable[[dict], dict]) -> None: + """The trigger is only offered if the widget declares it has a value.""" + result = schema({"width": 100, "on_value": [{"logger.log": "picked"}]}) + + assert "on_value" in result + + def test_value_argument_is_a_color(self) -> None: + """`x` must be an esphome Color, so that lambdas can use x.r/x.g/x.b. + + esphome::Color converts to lv_color_t implicitly, so declaring the wrong + one of the two still compiles but gives lambdas the LVGL struct, whose + members are named differently. + """ + (arg_type,) = color_picker_spec.w_type.get_arg_type() + + assert str(arg_type) == "Color" + + def test_update_action_sets_the_colour(self, update_schema: Schema) -> None: + """The colour is the one thing an update action is for.""" + assert update_schema({"id": "picker", "color": 0xFF8800}) is not None + + def test_update_action_does_not_require_width(self, update_schema: Schema) -> None: + """Width is only needed when the widget is created, unlike in the widget schema.""" + assert CONF_WIDTH not in update_schema({"id": "picker", "color": 0xFF8800}) + + def test_slider_parts_are_declared(self) -> None: + """`items` and `knob` stand for the sliders the widget is built from.""" + assert color_picker_spec.parts == ("main", "items", "knob") + + @pytest.mark.parametrize("part", ["items", "knob"]) + def test_slider_parts_are_accepted( + self, schema: Callable[[dict], dict], part: str + ) -> None: + assert schema({"width": 100, part: {"radius": 4}}) is not None + + +class TestColorPickerSliderChoice: + """Test choosing which of the six sliders the widget is built from.""" + + @pytest.fixture() + def schema(self) -> Callable[[dict], dict]: + return container_schema(color_picker_spec) + + def test_all_sliders_by_default(self, schema: Callable[[dict], dict]) -> None: + assert schema({"width": 100})["sliders"] == [ + "hue", + "saturation", + "brightness", + "red", + "green", + "blue", + ] + + @pytest.mark.parametrize( + ("configured", "expected"), + [ + ("rgb", ["red", "green", "blue"]), + ("hsv", ["hue", "saturation", "brightness"]), + # Both spellings of the third HSV component are accepted. + ("hsb", ["hue", "saturation", "brightness"]), + (["hue", "saturation"], ["hue", "saturation"]), + (["hue"], ["hue"]), + # A group can be mixed with single sliders. + (["rgb", "hue"], ["hue", "red", "green", "blue"]), + ], + ) + def test_groups_are_expanded( + self, + schema: Callable[[dict], dict], + configured: str | list[str], + expected: list[str], + ) -> None: + assert schema({"width": 100, "sliders": configured})["sliders"] == expected + + def test_order_follows_the_widget_not_the_config( + self, schema: Callable[[dict], dict] + ) -> None: + """The layout is fixed, so the order they are listed in makes no difference.""" + assert schema({"width": 100, "sliders": ["blue", "red"]})["sliders"] == [ + "red", + "blue", + ] + + def test_repeats_are_dropped(self, schema: Callable[[dict], dict]) -> None: + assert schema({"width": 100, "sliders": ["red", "rgb"]})["sliders"] == [ + "red", + "green", + "blue", + ] + + def test_empty_list_is_rejected(self, schema: Callable[[dict], dict]) -> None: + with pytest.raises(Invalid, match="At least one slider is required"): + schema({"width": 100, "sliders": []}) + + def test_unknown_slider_is_rejected(self, schema: Callable[[dict], dict]) -> None: + with pytest.raises(Invalid, match="Unknown value 'cyan'"): + schema({"width": 100, "sliders": ["cyan"]}) + + def test_sliders_cannot_be_updated(self, update_schema: Schema) -> None: + """The layout is built from them, so they cannot change after creation.""" + with pytest.raises(Invalid, match="sliders"): + update_schema({"id": "picker", "sliders": "rgb"}) + + +class TestColorPickerWheel: + """Test the `wheel` option, which replaces the hue and saturation sliders, and the RGB + sliders besides, with a circular hue/saturation picker. + + The exclusion of these sliders happens in `ColorPickerType.validate()`, which runs + before the schema itself (see `WIDGET_TYPES` validation in schemas.py), so these tests + reproduce that order rather than using the `container_schema` fixture alone. + """ + + @pytest.fixture() + def schema(self) -> Callable[[dict], dict]: + container = container_schema(color_picker_spec) + + def _validate(value: dict) -> dict: + return container(color_picker_spec.validate(value)) + + return _validate + + def test_wheel_defaults_to_false(self, schema: Callable[[dict], dict]) -> None: + assert schema({"width": 100})["wheel"] is False + + def test_wheel_must_be_a_boolean(self, schema: Callable[[dict], dict]) -> None: + with pytest.raises(Invalid, match="boolean"): + schema({"width": 100, "wheel": "round"}) + + def test_wheel_leaves_only_brightness_by_default( + self, schema: Callable[[dict], dict] + ) -> None: + assert schema({"width": 100, "wheel": True})["sliders"] == ["brightness"] + + def test_wheel_is_compatible_with_an_explicit_slider_list( + self, schema: Callable[[dict], dict] + ) -> None: + assert schema({"width": 100, "wheel": True, "sliders": "brightness"})[ + "sliders" + ] == ["brightness"] + + @pytest.mark.parametrize( + "sliders", + ["hue", "saturation", "red", "green", "blue", "rgb", ["hue", "red"], "hsv"], + ) + def test_wheel_conflicts_with_sliders_it_replaces( + self, schema: Callable[[dict], dict], sliders: str | list[str] + ) -> None: + with pytest.raises(Invalid, match="color wheel"): + schema({"width": 100, "wheel": True, "sliders": sliders}) + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- + +_SET_STYLE_RE = re.compile(r"lv_obj_set_style_(\w+)\((\w+)->obj, (.+?), \w+\);") + + +def _styles_for(main_cpp: str, var: str) -> dict[str, str]: + """Return {property: value} for every style set directly on a widget.""" + return { + m.group(1): m.group(3) + for m in _SET_STYLE_RE.finditer(main_cpp) + if m.group(2) == var + } + + +class TestColorPickerCodeGeneration: + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker.yaml")) + + def test_height_follows_width(self, main_cpp: str) -> None: + """The widget is square, so height is generated to match the given width.""" + for var, expected in ( + ("picker_content", "LV_SIZE_CONTENT"), + ("picker_fixed", "120"), + ("picker_colour_id", "lv_pct(50)"), + ): + styles = _styles_for(main_cpp, var) + assert styles["width"] == expected + assert styles["height"] == expected + + def test_color_is_applied(self, main_cpp: str) -> None: + assert "picker_fixed->set_color(" in main_cpp + assert "picker_colour_id->set_color(" in main_cpp + + @pytest.mark.parametrize("var", ["picker_content", "picker_rgb", "picker_wheel"]) + def test_color_is_omitted_when_not_configured( + self, main_cpp: str, var: str + ) -> None: + """Each of these can change saturation, so the grey it starts with is no trap.""" + assert f"{var}->set_color(" not in main_cpp + + def test_wheel_code_is_built_for_a_wheel(self, main_cpp: str) -> None: + from esphome.core import CORE + + assert "USE_LVGL_COLOR_PICKER_WHEEL" in {d.name for d in CORE.defines} + + def test_wheel_code_is_left_out_without_a_wheel( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """Its gradients would otherwise take RAM in every build with a picker.""" + from esphome.core import CORE + + generate_main(component_config_path("color_picker_theme.yaml")) + + assert "USE_LVGL_COLOR_PICKER_WHEEL" not in {d.name for d in CORE.defines} + + def test_hue_only_picker_starts_saturated(self, main_cpp: str) -> None: + """With no way to change saturation, a grey start would leave it stuck at grey.""" + assert "picker_hue->set_color(lv_color_make(255, 0, 0));" in main_cpp + + def test_black_is_applied(self, main_cpp: str) -> None: + """A hex colour validates to a plain int, and black must not read as absent.""" + assert "picker_black->set_color(lv_color_make(0, 0, 0))" in main_cpp + + def test_trigger_uses_color_type(self, main_cpp: str) -> None: + """The on_value trigger is declared with the Color the widget reports.""" + assert "Trigger" in main_cpp + + def test_trigger_reads_the_current_color(self, main_cpp: str) -> None: + assert "picker_colour_id->get_color()" in main_cpp + + def test_update_action_generates_set_color(self, main_cpp: str) -> None: + """`lvgl.color_picker.update` used to fail outright on the missing width.""" + assert main_cpp.count("picker_fixed->set_color(") == 2 + + def test_update_action_accepts_a_lambda(self, main_cpp: str) -> None: + # A lambda that only returns an expression is generated as that expression. + assert "picker_hue->set_color(lv_color_hex(0x123456));" in main_cpp + + @pytest.mark.parametrize("message", ["released", "updated"]) + def test_other_triggers_are_generated(self, main_cpp: str, message: str) -> None: + assert f'"{message}"' in main_cpp + + def test_gradient_stops_are_raised_for_the_hue_bar(self, main_cpp: str) -> None: + """The hue bar fills seven stops of a fixed-size array. + + `gradient.py` raises the limit when a colour picker is in use, and the C++ + `static_assert` relies on that having happened. It works because the widget + records the use during validation, which runs first, but nothing in either + file says so, hence this. + """ + from esphome.components.lvgl.defines import get_defines + + assert get_defines()["LV_GRADIENT_MAX_STOPS"] == "7" + + def test_gradient_stops_are_left_alone_without_a_picker( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """The other half of the same invariant: only a picker pays for the extra stops.""" + from esphome.components.lvgl.defines import get_defines + + # Any configuration without a colour picker in it will do. + generate_main(component_config_path("named_styles.yaml")) + + assert get_defines()["LV_GRADIENT_MAX_STOPS"] == "2" + + def test_complex_gradients_are_enabled_for_a_wheel(self, main_cpp: str) -> None: + """The wheel is a conical and a radial gradient, which need this to render at all.""" + from esphome.components.lvgl.defines import get_defines + + assert get_defines()["LV_USE_DRAW_SW_COMPLEX_GRADIENTS"] == "1" + + def test_complex_gradients_are_left_alone_without_a_wheel( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + from esphome.components.lvgl.defines import get_defines + + generate_main(component_config_path("named_styles.yaml")) + + assert "LV_USE_DRAW_SW_COMPLEX_GRADIENTS" not in get_defines() + + +# The widget's own object is a plain container, so styles for the parts a slider has are +# applied to each of its sliders instead. +_SLIDER_STYLE_RE = re.compile( + r"lv_obj_set_style_(\w+)\((\w+)->get_slider\(LvColorPickerType::SLIDER_(\w+)\), " + r"(.+?), (\w+)\);" +) + +ALL_SLIDERS = {"HUE", "SATURATION", "BRIGHTNESS", "RED", "GREEN", "BLUE"} + + +def _slider_style_values(main_cpp: str, var: str, prop: str) -> set[str]: + """Return every value one style property was set to across a widget's sliders.""" + return { + m.group(4) + for m in _SLIDER_STYLE_RE.finditer(main_cpp) + if m.group(2) == var and m.group(1) == prop and m.group(5) == "LV_PART_KNOB" + } + + +def _slider_styles(main_cpp: str, var: str) -> dict[tuple[str, str], set[str]]: + """Return {(property, selector): sliders it was applied to} for one widget.""" + found: dict[tuple[str, str], set[str]] = {} + for m in _SLIDER_STYLE_RE.finditer(main_cpp): + if m.group(2) == var: + found.setdefault((m.group(1), m.group(5)), set()).add(m.group(3)) + return found + + +class TestColorPickerSliderStyles: + @pytest.fixture() + def styles( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> dict[tuple[str, str], set[str]]: + main_cpp = generate_main(component_config_path("color_picker.yaml")) + return _slider_styles(main_cpp, "picker_styled") + + @pytest.mark.parametrize( + ("prop", "selector"), + [ + # `knob` keeps its name on the slider... + ("bg_color", "LV_PART_KNOB"), + ("radius", "LV_PART_KNOB"), + # ...while `items` becomes the slider's own body. + ("bg_opa", "LV_PART_MAIN"), + # A state without a part names only the state, as both are zero. + ("border_width", "LV_STATE_PRESSED"), + ], + ) + def test_style_reaches_every_slider( + self, styles: dict[tuple[str, str], set[str]], prop: str, selector: str + ) -> None: + assert styles[prop, selector] == ALL_SLIDERS + + def test_style_skips_sliders_the_widget_lacks( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """A style must not be applied to a slider that was never created.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + styles = _slider_styles(main_cpp, "picker_hsv") + + assert styles["radius", "LV_PART_KNOB"] == {"HUE", "SATURATION", "BRIGHTNESS"} + + @pytest.mark.parametrize( + "selector", + [ + # A part on its own needs no cast... + "LV_PART_KNOB", + # ...while a part combined with a state is cast to the type LVGL takes, which is + # wide enough for both. A state alone would not be. + ( + "(static_cast(LV_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ), + ], + ) + def test_named_style_reaches_every_slider( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + selector: str, + ) -> None: + """A style listed under `styles:` is added to each slider, not to the container.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + added = re.findall( + r"lv_obj_add_style\(picker_styled->get_slider\(" + r"LvColorPickerType::SLIDER_(\w+)\), picker_style, (.+?)\);", + main_cpp, + ) + + assert {name for name, sel in added if sel == selector} == ALL_SLIDERS + + def test_a_lambda_is_generated_once_for_all_the_sliders( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """One YAML value must not become one C++ function per slider.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + + # The source also appears once in the comment codegen writes above the lambda. + assert main_cpp.count("int border_width_VAR_ = []() -> int {") == 1 + + def test_the_same_lambda_reaches_every_slider( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """Generating it once must not mean applying it once.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + styles = _slider_styles(main_cpp, "picker_styled") + + assert styles["border_width", "LV_PART_KNOB"] == ALL_SLIDERS + assert _slider_style_values(main_cpp, "picker_styled", "border_width") == { + "border_width_VAR_" + } + + def test_no_styles_are_left_on_the_widget_itself( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> None: + """Setting these on the container would have no visible effect.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + own = _styles_for(main_cpp, "picker_styled") + + assert set(own) == {"width", "height"} + + @pytest.mark.parametrize( + "var", + [ + # The background is set on the knob directly... + "picker_styled", + # ...or through a named style, which the tint would still outrank. + "picker_knob_style", + ], + ) + def test_knob_tinting_is_disabled_by_a_knob_background( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + var: str, + ) -> None: + """The tint is a local style, so it would overwrite the configured colour.""" + main_cpp = generate_main(component_config_path("color_picker.yaml")) + + assert f"{var}->set_tint_knobs(false)" in main_cpp + + @pytest.mark.parametrize( + "var", ["picker_content", "picker_fixed", "picker_colour_id"] + ) + def test_knob_tinting_is_left_on_otherwise( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + var: str, + ) -> None: + main_cpp = generate_main(component_config_path("color_picker.yaml")) + + assert f"{var}->set_tint_knobs(" not in main_cpp + + +class TestColorPickerObjectProperties: + """The widget's own object is never pressed, so touch settings go to the sliders.""" + + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker.yaml")) + + def test_ext_click_area_reaches_the_sliders(self, main_cpp: str) -> None: + """This is what makes the knobs easier to grab: it widens each slider's touch area.""" + found = re.findall( + r"lv_obj_set_ext_click_area\(picker_rgb->get_slider\(" + r"LvColorPickerType::SLIDER_(\w+)\), 12\);", + main_cpp, + ) + + assert set(found) == {"RED", "GREEN", "BLUE"} + + def test_ext_click_area_is_not_left_on_the_widget_itself( + self, main_cpp: str + ) -> None: + """The container has nothing clickable in it, so setting it there does nothing.""" + assert "lv_obj_set_ext_click_area(picker_rgb->obj" not in main_cpp + + def test_ext_click_area_under_a_part_is_reported(self, main_cpp: str) -> None: + """It takes no part, so LVGL cannot apply it to one; that must not pass silently.""" + from esphome.components.lvgl.defines import get_warnings + + assert any( + "'ext_click_area'" in w and "'knob'" in w and "'picker_styled'" in w + for w in get_warnings() + ), get_warnings() + + def test_scrollbar_mode_stays_on_the_widget_itself(self, main_cpp: str) -> None: + """Only the touch margin moves: a slider never scrolls, but the container can.""" + assert "lv_obj_set_scrollbar_mode(picker_rgb->obj, LV_SCROLLBAR_MODE_OFF)" in ( + main_cpp + ) + + def test_scrollbar_mode_does_not_reach_the_sliders(self, main_cpp: str) -> None: + assert "lv_obj_set_scrollbar_mode(picker_rgb->get_slider(" not in main_cpp + + +class TestColorPickerSliderConstruction: + """The chosen sliders are passed to the constructor, before the layout is built.""" + + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker.yaml")) + + @pytest.mark.parametrize( + ("var", "expected"), + [ + ("picker_rgb", ["RED", "GREEN", "BLUE"]), + ("picker_hsv", ["HUE", "SATURATION", "BRIGHTNESS"]), + ("picker_hue", ["HUE"]), + ( + "picker_fixed", + ["HUE", "SATURATION", "BRIGHTNESS", "RED", "GREEN", "BLUE"], + ), + ], + ) + def test_constructor_names_the_chosen_sliders( + self, main_cpp: str, var: str, expected: list[str] + ) -> None: + flags = " | ".join( + f"LvColorPickerType::SLIDER_FLAG_{name}" for name in expected + ) + + assert f"new({var}) LvColorPickerType({flags}, false);" in main_cpp + + def test_constructor_passes_wheel_true(self, main_cpp: str) -> None: + assert ( + "new(picker_wheel) LvColorPickerType(" + "LvColorPickerType::SLIDER_FLAG_BRIGHTNESS, true);" + ) in main_cpp + + def test_constructor_passes_wheel_false_by_default(self, main_cpp: str) -> None: + assert "new(picker_hue) LvColorPickerType(" in main_cpp + assert "LvColorPickerType::SLIDER_FLAG_HUE, false);" in main_cpp + + +class TestColorPickerTheme: + """Settings that come from the theme or a style definition rather than the widget.""" + + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("color_picker_theme.yaml")) + + def test_theme_knob_color_turns_tinting_off(self, main_cpp: str) -> None: + """The tint is a local style, so it would overwrite the themed colour.""" + assert "themed_picker->set_tint_knobs(false)" in main_cpp + + @pytest.mark.parametrize( + "expected", + [ + ("'ext_click_area'", "style 'clickable_style'"), + ("'scrollbar_mode'", "the theme for 'button'"), + ("'ext_click_area'", "the theme for 'button' under 'pressed'"), + ], + ) + def test_widget_properties_in_styles_are_reported( + self, main_cpp: str, expected: tuple[str, str] + ) -> None: + """A style only holds style properties, so these would be dropped silently.""" + from esphome.components.lvgl.defines import get_warnings + + prop, place = expected + assert any(prop in w and f"in {place} " in w for w in get_warnings()), ( + get_warnings() + ) diff --git a/tests/component_tests/lvgl/test_named_styles.py b/tests/component_tests/lvgl/test_named_styles.py new file mode 100644 index 0000000000..0fba00cd3a --- /dev/null +++ b/tests/component_tests/lvgl/test_named_styles.py @@ -0,0 +1,57 @@ +"""Tests for the selector a named style is added to a widget with. + +A part and a state are combined into one value, but they live in different halves of it: +a state fits in 16 bits, while a part starts at bit 16. Casting the pair to the state type +would throw the part away and quietly move the style to the main part, so these pin the +type each selector is generated with. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + + +def _selectors(main_cpp: str, var: str, style: str) -> set[str]: + """Return every selector a named style was added to a widget with.""" + return set( + re.findall( + rf"lv_obj_add_style\({var}, {style}, (.+?)\);", + main_cpp, + ) + ) + + +class TestNamedStyleSelectors: + @pytest.fixture() + def main_cpp( + self, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + ) -> str: + return generate_main(component_config_path("named_styles.yaml")) + + def test_every_selector_is_generated(self, main_cpp: str) -> None: + assert _selectors(main_cpp, "plain_slider", "chunky") == { + # Neither a part nor a state, which names the main part. + "LV_PART_MAIN", + # A state on its own, which needs no cast as it is the narrower half. + "LV_STATE_PRESSED", + # A part on its own. + "LV_PART_KNOB", + # A part with a state, cast to the type LVGL takes, which is wide enough for + # both. The state type alone would drop the part. + ( + "(static_cast(LV_STATE_PRESSED) | " + "static_cast(LV_PART_KNOB))" + ), + } + + def test_a_combined_selector_is_not_narrowed_to_a_state( + self, main_cpp: str + ) -> None: + """`lv_state_t` is 16 bits, so it cannot hold a part.""" + assert "(lv_state_t)((int)LV_PART_KNOB" not in main_cpp diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 681eb41b5b..952dd5f50f 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1777,6 +1777,58 @@ lvgl: - label: text: "2,2" + # Sized from its text font, with a colour given as an id + - color_picker: + id: lv_color_picker_1 + width: SIZE_CONTENT + text_font: space16 + color: light_blue + on_value: + - logger.log: + format: "color picker is now %d,%d,%d" + args: [x.r, x.g, x.b] + on_release: + - lvgl.color_picker.update: + id: lv_color_picker_2 + color: !lambda return x; + + # Fixed size, with a colour given as a hex literal + - color_picker: + id: lv_color_picker_2 + width: 200 + color: 0x3366CC + # Styles for the sliders the widget is built from + items: + border_color: 0x808080 + pressed: + border_width: 2 + knob: + radius: 4 + # A named style, added for both a part on its own and a part with a state + styles: [style_test] + pressed: + styles: [style_test] + on_update: + - logger.log: color picker updated + + # Only the red, green and blue sliders + - color_picker: + id: lv_color_picker_3 + width: 150 + sliders: rgb + + # A hue and saturation pair, as used where brightness is set elsewhere + - color_picker: + id: lv_color_picker_4 + width: 150 + sliders: [hue, saturation] + # Widens the area each slider can be pressed on, so the knobs are easier to move + ext_click_area: 10 + # Applies to the widget as a whole, unlike the setting above + scrollbar_mode: "OFF" + knob: + bg_color: 0xFFFFFF + font: - file: "gfonts://Roboto" id: space16 diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 1df27d82f7..3c4825b067 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -79,6 +79,11 @@ lvgl: - lvgl.animation.start: id: anim_slide lvgl_id: lvgl_0 + # Sliders only and no complex gradients, so the wheel's gradient set-up must still build + - color_picker: + id: host_hue_picker + width: 200 + sliders: hue font: - file: "gfonts://Roboto" diff --git a/tests/integration/fixtures/lvgl_color_picker_render.yaml b/tests/integration/fixtures/lvgl_color_picker_render.yaml new file mode 100644 index 0000000000..320b757860 --- /dev/null +++ b/tests/integration/fixtures/lvgl_color_picker_render.yaml @@ -0,0 +1,82 @@ +esphome: + name: lvgl-color-picker-render + +host: + +api: + actions: + # A snapshot never writes over an existing file, so the test supplies a new name each time. + - action: take_screenshot + variables: + name: string + then: + - snapshot.take: + id: picker_display + filename: !lambda return name; + - action: set_orange + then: + - lvgl.color_picker.update: + id: picker_sliders + color: 0xFF8800 + - lvgl.color_picker.update: + id: picker_wheel + color: 0xFF8800 + # White has no saturation, which a hue-only picker could never change again + - lvgl.color_picker.update: + id: picker_hue + color: 0xFFFFFF + +logger: + level: DEBUG + +display: + - platform: snapshot + id: picker_display + auto_clear_enabled: false + dimensions: + width: 680 + height: 480 + +lvgl: + displays: picker_display + default_font: montserrat_14 + widgets: + - obj: + width: 100% + height: 100% + bg_color: 0x404040 + bg_opa: cover + border_width: 0 + radius: 0 + pad_all: 4 + layout: + type: flex + flex_flow: row_wrap + pad_row: 4 + pad_column: 4 + widgets: + - color_picker: + id: picker_sliders + width: 196 + color: 0x3366CC + - color_picker: + id: picker_wheel + width: 196 + wheel: true + color: 0x3366CC + # Sized from the text font + - color_picker: + id: picker_content + color: 0x3366CC + # No red, green and blue bars beside the swatch + - color_picker: + id: picker_hsv + width: 196 + sliders: hsv + color: 0x3366CC + # Only the hue bar + - color_picker: + id: picker_hue + width: 196 + sliders: hue + color: 0x3366CC diff --git a/tests/integration/test_lvgl_color_picker_render.py b/tests/integration/test_lvgl_color_picker_render.py new file mode 100644 index 0000000000..0204f29312 --- /dev/null +++ b/tests/integration/test_lvgl_color_picker_render.py @@ -0,0 +1,105 @@ +"""Golden-image test for the LVGL colour picker, drawn on a display with no screen. + +Pickers are drawn with every slider, with a colour wheel, at the size taken from the font, +and with only some of the sliders. The first two are then given a new colour by +`lvgl.color_picker.update`, and the hue-only one is given white, and they are drawn again. Each picture is compared against a hash, as in +test_lvgl_headless_render.py. If a change alters the output, the test fails, keeps the image it +saw and prints its hash; update the hash once the image has been looked at and found to be +correct. +""" + +from __future__ import annotations + +import asyncio +import hashlib +from pathlib import Path + +import pytest + +from .artifact_utils import keep_artifact +from .bmp_utils import Bmp, capture_when_drawn, wait_for_bmp +from .types import APIClientConnectedFactory, RunCompiledFunction + +WIDTH = 680 +HEIGHT = 480 + +# Every picker showing 0x3366CC. +EXPECTED_INITIAL_SHA256 = ( + "5d93f4bd98fcbe418ef8d34572e3b0912e0421cfd87e61e8cc3c49778a0e0e47" +) +# The first two pickers showing 0xFF8800, and the hue-only one red rather than stuck at white, +# after the update action. +EXPECTED_UPDATED_SHA256 = ( + "8300a6d95f38e60ea3d175aa413e71b99de6ac7ad78c9adf5248bfb350cb35d3" +) +# The LVGL version the hashes were generated against; a version change can move them. +EXPECTED_LVGL_VERSION = "9.6.0" + +REDRAW_TIMEOUT = 5.0 + + +def _check(image: Bmp, capture: Path, expected: str, name: str) -> None: + assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24) + digest = hashlib.sha256(image.pixels).hexdigest() + if digest == expected: + return + kept = keep_artifact(f"lvgl_color_picker_{name}_actual.bmp", capture.read_bytes()) + + from esphome.components.lvgl import LVGL_VERSION + + version_hint = "" + if LVGL_VERSION != EXPECTED_LVGL_VERSION: + version_hint = ( + f"the bundled LVGL version changed ({EXPECTED_LVGL_VERSION} -> " + f"{LVGL_VERSION}), which is the likely cause\n" + ) + pytest.fail( + f"{name} screen does not match the expected hash\n" + f"{version_hint}" + f" expected: {expected}\n" + f" actual: {digest}\n" + f"the image that was rendered has been kept at {kept}" + ) + + +@pytest.mark.asyncio +async def test_lvgl_color_picker_render( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The colour picker draws as expected, before and after its colour is changed.""" + snapshot_dir = tmp_path / "snapshots" + monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir)) + + async with run_compiled(yaml_config), api_client_connected() as client: + _, services = await client.list_entities_services() + take_screenshot = next(s for s in services if s.name == "take_screenshot") + set_orange = next(s for s in services if s.name == "set_orange") + + async def take(name: str) -> None: + await client.execute_service(take_screenshot, {"name": name}) + + image, capture = await capture_when_drawn(take, snapshot_dir, prefix="initial") + _check(image, capture, EXPECTED_INITIAL_SHA256, "initial") + + await client.execute_service(set_orange, {}) + # The redraw follows the update on a later LVGL tick, so capture until the picture changes. + loop = asyncio.get_running_loop() + deadline = loop.time() + REDRAW_TIMEOUT + attempt = 0 + while True: + attempt += 1 + capture = snapshot_dir / f"updated-{attempt}.bmp" + await take(capture.name) + updated = await wait_for_bmp(capture) + if updated.pixels != image.pixels: + break + if loop.time() >= deadline: + pytest.fail( + f"the screen did not change within {REDRAW_TIMEOUT}s of the update" + ) + await asyncio.sleep(0.2) + _check(updated, capture, EXPECTED_UPDATED_SHA256, "updated") From 19eeab0385d08ec54c40889c241ebb3b83ef6300 Mon Sep 17 00:00:00 2001 From: Chris Boot Date: Mon, 12 Oct 2026 05:48:13 +0100 Subject: [PATCH 155/268] [wifi] Add DHCP lease cache (#17767) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 42 ++++ esphome/components/wifi/wifi_component.cpp | 41 +++- esphome/components/wifi/wifi_component.h | 42 ++++ .../wifi/wifi_component_esp_idf.cpp | 206 ++++++++++++++++-- esphome/core/defines.h | 2 + .../component_tests/wifi/test_lease_cache.py | 41 ++++ .../wifi/test-lease-cache.esp32-idf.yaml | 8 + .../validate-lease-cache-flash.esp32-idf.yaml | 13 ++ 8 files changed, 375 insertions(+), 20 deletions(-) create mode 100644 tests/component_tests/wifi/test_lease_cache.py create mode 100644 tests/components/wifi/test-lease-cache.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index aa6d113e16..61f242d8ca 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -62,6 +62,7 @@ from esphome.const import ( CONF_USE_ADDRESS, CONF_USERNAME, PLACEHOLDER_WIFI_SSID, + Framework, Platform, PlatformFramework, ) @@ -490,6 +491,38 @@ def _fast_connect_schema(value: Any) -> ConfigType: return FAST_CONNECT_SCHEMA(value) +CONF_LEASE_CACHE = "lease_cache" +CONF_MAX_AGE = "max_age" + +LEASE_CACHE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + # Defaults to RTC in _lease_cache_schema; flash wears and must be opted into. + cv.Optional(CONF_STORAGE): preferences.validate_storage, + cv.Optional(CONF_MAX_AGE): cv.positive_time_period_seconds, + } +) + + +def _lease_cache_schema(value: Any) -> ConfigType: + """Accept a plain boolean shorthand or a dict with enabled/storage/max_age keys.""" + if not isinstance(value, dict): + value = {CONF_ENABLED: value} + value = LEASE_CACHE_SCHEMA(value) + if value[CONF_ENABLED]: + cv.only_on_esp32(value) + cv.only_with_framework(Framework.ESP_IDF)(value) + if CONF_STORAGE not in value: + if not preferences.rtc_supported(): + raise cv.Invalid( + "This ESP32 variant has no RTC memory; set 'storage: flash' to " + "cache the lease in flash", + path=[CONF_STORAGE], + ) + value[CONF_STORAGE] = preferences.STORAGE_RTC + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -518,6 +551,7 @@ CONFIG_SCHEMA = cv.All( ln882x="none", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), cv.Optional(CONF_FAST_CONNECT, default=False): _fast_connect_schema, + cv.Optional(CONF_LEASE_CACHE, default=False): _lease_cache_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, @@ -723,6 +757,14 @@ async def to_code(config): # used flash (the in_flash flag was previously ignored outside ESP8266). if preferences.is_in_flash(fast_connect[CONF_STORAGE]): cg.add_define("USE_WIFI_FAST_CONNECT_IN_FLASH") + lease_cache = config[CONF_LEASE_CACHE] + if lease_cache[CONF_ENABLED]: + cg.add_define("USE_WIFI_LEASE_CACHE") + # is_in_flash() also emits USE_ESP32_RTC_PREFERENCES when rtc is chosen. + if preferences.is_in_flash(lease_cache[CONF_STORAGE]): + cg.add_define("USE_WIFI_LEASE_CACHE_IN_FLASH") + if (max_age := lease_cache.get(CONF_MAX_AGE)) is not None: + cg.add(var.set_lease_cache_max_age(int(max_age.total_seconds))) # passive_scan defaults to false in C++ - only set if true if config[CONF_PASSIVE_SCAN]: cg.add(var.set_passive_scan(True)) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 001dae98b8..9a9ca2cbb2 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -398,7 +398,6 @@ static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000; /// disconnect event cannot keep the networks from being tried for good. static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS; static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000; -static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; #endif /// Cooldown duration when fallback AP is active and captive portal may be running @@ -702,6 +701,15 @@ void WiFiComponent::start() { this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, fast_connect_in_flash); #endif +#ifdef USE_WIFI_LEASE_CACHE +#ifdef USE_WIFI_LEASE_CACHE_IN_FLASH + const bool lease_cache_in_flash = true; +#else + const bool lease_cache_in_flash = false; +#endif + this->lease_cache_pref_ = + global_preferences->make_preference(hash + 2, lease_cache_in_flash); +#endif SavedWifiSettings save{}; if (this->pref_.load(&save)) { @@ -712,6 +720,10 @@ void WiFiComponent::start() { sta.set_password(save.password); this->set_sta(sta); } +#ifdef USE_WIFI_LEASE_CACHE + // After the saved credentials: set_sta() clears the cache, and the lease belongs to that same network + this->load_lease_settings_(); +#endif if (this->has_sta()) { this->wifi_sta_pre_setup_(); @@ -1149,6 +1161,10 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->selected_sta_index_ = 0; // When new credentials are set (e.g., from improv), skip cooldown to retry immediately this->skip_cooldown_next_cycle_ = true; +#ifdef USE_WIFI_LEASE_CACHE + // Index 0 is a different network now, so its cached lease must not be re-applied + this->clear_lease_settings_(); +#endif } WiFiAP WiFiComponent::build_params_for_current_phase_() { @@ -1448,6 +1464,9 @@ void WiFiComponent::disable() { ESP_LOGD(TAG, "Disabling"); this->state_ = WIFI_COMPONENT_STATE_DISABLED; +#ifdef USE_WIFI_LEASE_CACHE + this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); +#endif this->wifi_disconnect_(); this->wifi_mode_(false, false); } @@ -2440,6 +2459,26 @@ void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t ch } #endif +#ifdef USE_WIFI_LEASE_CACHE +void WiFiComponent::load_lease_settings_() { + if (!this->lease_cache_pref_.load(&this->cached_lease_) || this->cached_lease_.version != SAVED_WIFI_LEASE_VERSION) { + this->cached_lease_ = {}; + return; + } + const uint8_t *ip = reinterpret_cast(&this->cached_lease_.ip); // network byte order + ESP_LOGD(TAG, "Loaded cached lease %u.%u.%u.%u (network %d, lease %us)", ip[0], ip[1], ip[2], ip[3], + this->cached_lease_.ap_index, this->cached_lease_.lease_secs); +} + +void WiFiComponent::clear_lease_settings_() { + if (this->cached_lease_.version == 0) + return; + this->cached_lease_ = {}; + this->lease_cache_pref_.save(&this->cached_lease_); + ESP_LOGD(TAG, "Lease cache: cleared"); +} +#endif + WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden) : bssid_(bssid), diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 9700e36e6f..73344f66fa 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -86,6 +86,25 @@ struct SavedWifiFastConnectSettings { int8_t ap_index; } PACKED; // NOLINT +#ifdef USE_WIFI_LEASE_CACHE +// Checked on load; a zeroed record (version 0) means no cached lease. +static constexpr uint8_t SAVED_WIFI_LEASE_VERSION = 1; + +// Cached DHCP lease. IPv4 fields in network byte order, times in seconds. +struct SavedWifiLeaseSettings { + uint8_t version; + int8_t ap_index; + uint32_t ip; + uint32_t netmask; + uint32_t gateway; + uint32_t dns1; + uint32_t dns2; + uint32_t lease_secs; + uint32_t t1_renew; + uint32_t saved_at; // RTC-domain clock at capture +} PACKED; // NOLINT +#endif + enum WiFiComponentState : uint8_t { /** Nothing has been initialized yet. Internal AP, if configured, is disabled at this point. */ WIFI_COMPONENT_STATE_OFF = 0, @@ -429,6 +448,10 @@ class WiFiPowerSaveListener { virtual void on_wifi_power_save(WiFiPowerSaveMode mode) = 0; }; +// Scheduler ids +static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; +static constexpr uint32_t LEASE_RENEW_TIMEOUT_ID = 1; + /// This component is responsible for managing the ESP WiFi interface. class WiFiComponent final : public Component { public: @@ -565,6 +588,9 @@ class WiFiComponent final : public Component { int8_t wifi_rssi(); void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } +#ifdef USE_WIFI_LEASE_CACHE + void set_lease_cache_max_age(uint32_t seconds) { this->lease_cache_max_age_ = seconds; } +#endif void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; } void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; } @@ -813,6 +839,15 @@ class WiFiComponent final : public Component { void save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel); #endif +#ifdef USE_WIFI_LEASE_CACHE + void load_lease_settings_(); + void save_lease_settings_(); + void clear_lease_settings_(); + // Cached lease belongs to the selected network and is still provably valid. + bool lease_valid_for_apply_() const; + void renew_cached_lease_(); +#endif + // Post-connect roaming methods void check_roaming_(uint32_t now); void process_roaming_scan_(); @@ -899,6 +934,13 @@ class WiFiComponent final : public Component { #ifdef USE_WIFI_FAST_CONNECT ESPPreferenceObject fast_connect_pref_; #endif +#ifdef USE_WIFI_LEASE_CACHE + ESPPreferenceObject lease_cache_pref_; + SavedWifiLeaseSettings cached_lease_{}; // version 0 = none + // Current IP came from the cache (DHCP not run); suppresses saving until a real lease arrives. + bool applied_cached_lease_{false}; + uint32_t lease_cache_max_age_{0}; // 0 = no cap +#endif #ifdef USE_WIFI_CONNECT_TRIGGER Trigger<> connect_trigger_; #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 3769e26831..388e3edb98 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -35,6 +35,12 @@ #include "lwip/apps/sntp.h" #include "lwip/dns.h" #include "lwip/err.h" +#ifdef USE_WIFI_LEASE_CACHE +#include // esp_netif_get_netif_impl() +#include // esp_rtc_get_time_us() -- RTC-domain clock for lease age +#include "lwip/dhcp.h" +#include "lwip/netif.h" +#endif #include "esphome/core/application.h" #include "esphome/core/hal.h" @@ -553,6 +559,68 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { return true; } +// sntp_servermode_dhcp() is an empty macro unless lwIP takes NTP servers from DHCP; that build needs the core lock +static void disable_dhcp_sntp() { +#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 + LwIPLock lock; +#endif + sntp_servermode_dhcp(false); +} + +#ifdef USE_WIFI_LEASE_CACHE +// Minimum lease time that must remain before a cached lease is applied. +static constexpr uint32_t LEASE_APPLY_MARGIN_SECS = 60; + +// Seconds since capture on the RTC-domain clock; UINT32_MAX if the clock was reset (age unknown). +static uint32_t lease_cache_age_secs(const SavedWifiLeaseSettings &lease) { + uint32_t now = static_cast(esp_rtc_get_time_us() / 1000000ULL); + return now >= lease.saved_at ? now - lease.saved_at : UINT32_MAX; +} + +// Renewal deadline (T1) from capture; half the lease when T1 is missing or invalid (RFC 2131). +static uint32_t lease_renew_at_secs(const SavedWifiLeaseSettings &lease) { + uint32_t renew_at = lease.t1_renew; + if (renew_at == 0 || renew_at >= lease.lease_secs) + renew_at = lease.lease_secs / 2; + return renew_at; +} + +// Starting the DHCP client drops the static address and DNS servers, so open connections reset +void WiFiComponent::renew_cached_lease_() { + ESP_LOGI(TAG, "Cached lease renew deadline reached; re-acquiring the address over DHCP, connections may drop"); + this->applied_cached_lease_ = false; + // Same SNTP leak guard as the DHCP path: https://github.com/esphome/issues/issues/2299 + disable_dhcp_sntp(); + esp_err_t err = esp_netif_dhcpc_start(s_sta_netif); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Lease renew: starting DHCP client failed: %s, retrying in 60 s", esp_err_to_name(err)); + this->set_timeout(LEASE_RENEW_TIMEOUT_ID, 60000, [this]() { this->renew_cached_lease_(); }); + } +} + +bool WiFiComponent::lease_valid_for_apply_() const { + const SavedWifiLeaseSettings &lease = this->cached_lease_; + if (lease.version != SAVED_WIFI_LEASE_VERSION || this->selected_sta_index_ < 0 || + lease.ap_index != this->selected_sta_index_) + return false; + // The age is only known when the RTC clock survived the reset (deep sleep or software reboot). + esp_reset_reason_t reason = esp_reset_reason(); + if (reason != ESP_RST_DEEPSLEEP && reason != ESP_RST_SW) + return false; + uint32_t age = lease_cache_age_secs(lease); + if (age == UINT32_MAX) + return false; + if (this->lease_cache_max_age_ != 0 && age > this->lease_cache_max_age_) + return false; + // Past T1 the lease needs DHCP renewal anyway. + if (age >= lease_renew_at_secs(lease)) + return false; + // Scale the margin with the lease: the RTC RC oscillator drifts a few percent. + uint32_t margin = std::max(LEASE_APPLY_MARGIN_SECS, lease.lease_secs / 16); + return lease.lease_secs > age + margin; +} +#endif + bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) @@ -571,18 +639,43 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { return false; } - if (!manual_ip.has_value()) { +#ifdef USE_WIFI_LEASE_CACHE + // Without a manual IP, a still-valid cached lease is applied as a static IP to skip DHCP. + optional effective_ip = manual_ip; + this->applied_cached_lease_ = false; + this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); + if (!effective_ip.has_value() && this->lease_valid_for_apply_()) { + const SavedWifiLeaseSettings &lease = this->cached_lease_; + esp_ip4_addr_t ip4{lease.ip}, mask4{lease.netmask}, gw4{lease.gateway}, dns1_4{lease.dns1}, dns2_4{lease.dns2}; + ManualIP cached; + cached.static_ip = network::IPAddress(&ip4); + cached.gateway = network::IPAddress(&gw4); + cached.subnet = network::IPAddress(&mask4); + cached.dns1 = network::IPAddress(&dns1_4); + cached.dns2 = network::IPAddress(&dns2_4); + effective_ip = cached; + this->applied_cached_lease_ = true; + + uint32_t age = lease_cache_age_secs(lease); + uint32_t remaining = lease.lease_secs > age ? lease.lease_secs - age : 0; + ESP_LOGI(TAG, "Applying cached lease " IPSTR ", ~%us left, skipping DHCP", IP2STR(&ip4), remaining); + + // Still awake at T1: switch to real DHCP. lease_valid_for_apply_() guarantees age < T1. + uint32_t renew_in = lease_renew_at_secs(lease) - age; + // Clamp below UINT32_MAX, which the scheduler treats as "don't run". + uint32_t renew_ms = + static_cast(std::min(static_cast(renew_in) * 1000ULL, UINT32_MAX - 1)); + this->set_timeout(LEASE_RENEW_TIMEOUT_ID, renew_ms, [this]() { this->renew_cached_lease_(); }); + } +#else + const optional &effective_ip = manual_ip; +#endif + + if (!effective_ip.has_value()) { // lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, // the built-in SNTP client has a memory leak in certain situations. Disable this feature. // https://github.com/esphome/issues/issues/2299 - { -#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 - // sntp_servermode_dhcp() is an empty macro unless lwIP is built with - // DHCP-supplied NTP servers, so only that build needs the core lock. - LwIPLock lock; -#endif - sntp_servermode_dhcp(false); - } + disable_dhcp_sntp(); // No manual IP is set; use DHCP client if (dhcp_status != ESP_NETIF_DHCP_STARTED) { @@ -596,9 +689,9 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { } esp_netif_ip_info_t info; // struct of ip4_addr_t with ip, netmask, gw - info.ip = manual_ip->static_ip; - info.gw = manual_ip->gateway; - info.netmask = manual_ip->subnet; + info.ip = effective_ip->static_ip; + info.gw = effective_ip->gateway; + info.netmask = effective_ip->subnet; err = esp_netif_dhcpc_stop(s_sta_netif); if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { ESP_LOGV(TAG, "Stopping DHCP client failed: %s", esp_err_to_name(err)); @@ -610,18 +703,82 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { } esp_netif_dns_info_t dns; - if (manual_ip->dns1.is_set()) { - dns.ip = manual_ip->dns1; + if (effective_ip->dns1.is_set()) { + dns.ip = effective_ip->dns1; esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_MAIN, &dns); } - if (manual_ip->dns2.is_set()) { - dns.ip = manual_ip->dns2; + if (effective_ip->dns2.is_set()) { + dns.ip = effective_ip->dns2; esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_BACKUP, &dns); } return true; } +#ifdef USE_WIFI_LEASE_CACHE +void WiFiComponent::save_lease_settings_() { + // A cached lease applied as a static IP has no DHCP timing to capture. + if (s_sta_netif == nullptr || this->applied_cached_lease_) + return; + +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) + return; +#endif + + esp_netif_ip_info_t info; + if (esp_netif_get_ip_info(s_sta_netif, &info) != ESP_OK || info.ip.addr == 0) + return; + + SavedWifiLeaseSettings lease{}; + lease.version = SAVED_WIFI_LEASE_VERSION; + lease.ap_index = this->selected_sta_index_; + lease.ip = info.ip.addr; + lease.netmask = info.netmask.addr; + lease.gateway = info.gw.addr; + + esp_netif_dns_info_t dns; + if (esp_netif_get_dns_info(s_sta_netif, ESP_NETIF_DNS_MAIN, &dns) == ESP_OK && dns.ip.type == ESP_IPADDR_TYPE_V4) { + lease.dns1 = dns.ip.u_addr.ip4.addr; + } + if (esp_netif_get_dns_info(s_sta_netif, ESP_NETIF_DNS_BACKUP, &dns) == ESP_OK && dns.ip.type == ESP_IPADDR_TYPE_V4) { + lease.dns2 = dns.ip.u_addr.ip4.addr; + } + + // Lease timing is only in lwIP's internal DHCP state; without it the lease can't be aged. + bool have_timing = false; + if (auto *impl = static_cast(esp_netif_get_netif_impl(s_sta_netif))) { + LwIPLock lock; + if (struct dhcp *d = netif_dhcp_data(impl)) { + lease.lease_secs = d->offered_t0_lease; + lease.t1_renew = d->offered_t1_renew; + have_timing = true; + } + } + if (!have_timing) + return; + + lease.saved_at = static_cast(esp_rtc_get_time_us() / 1000000ULL); + + // On flash, skip a write that would only bump saved_at (an older one only overstates the age), + // unless it is from a previous RTC clock period (power cycle). +#ifdef USE_WIFI_LEASE_CACHE_IN_FLASH + constexpr size_t compare_len = offsetof(SavedWifiLeaseSettings, saved_at); +#else + constexpr size_t compare_len = sizeof(SavedWifiLeaseSettings); +#endif + const SavedWifiLeaseSettings &previous = this->cached_lease_; + if (previous.saved_at <= lease.saved_at && memcmp(&previous, &lease, compare_len) == 0) + return; + + this->lease_cache_pref_.save(&lease); + this->cached_lease_ = lease; + + ESP_LOGD(TAG, "Lease cache: saved " IPSTR " (network %d, lease %us)", IP2STR(&info.ip), lease.ap_index, + lease.lease_secs); +} +#endif + esp_netif_t *WiFiComponent::get_esp_netif_sta() { return s_sta_netif; } network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { @@ -853,9 +1010,17 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { // This ensures wifi.connected condition returns true in listener automations this->pending_.connect_state = true; #endif - // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here -#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + // For static IP configurations (manual or cached lease), GOT_IP event may not fire, so notify IP listeners here +#if defined(USE_WIFI_IP_STATE_LISTENERS) && (defined(USE_WIFI_MANUAL_IP) || defined(USE_WIFI_LEASE_CACHE)) + bool static_ip = false; +#ifdef USE_WIFI_MANUAL_IP + const WiFiAP *config = this->get_selected_sta_(); + static_ip = config && config->get_manual_ip().has_value(); +#endif +#ifdef USE_WIFI_LEASE_CACHE + static_ip = static_ip || this->applied_cached_lease_; +#endif + if (static_ip) { this->notify_ip_state_listeners_(); } #endif @@ -889,6 +1054,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { const auto &it = data->data.ip_got_ip; ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; +#ifdef USE_WIFI_LEASE_CACHE + this->save_lease_settings_(); // events are drained on the main loop +#endif #ifdef USE_WIFI_IP_STATE_LISTENERS this->notify_ip_state_listeners_(); #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 00e53765c2..7ff80cbbd1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -464,6 +464,8 @@ #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH +#define USE_WIFI_LEASE_CACHE +#define USE_WIFI_LEASE_CACHE_IN_FLASH #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS diff --git a/tests/component_tests/wifi/test_lease_cache.py b/tests/component_tests/wifi/test_lease_cache.py new file mode 100644 index 0000000000..56a9f1b66b --- /dev/null +++ b/tests/component_tests/wifi/test_lease_cache.py @@ -0,0 +1,41 @@ +"""Tests for the lease_cache storage validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32C2 +from esphome.components.wifi import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(lease_cache: dict) -> dict: + return CONFIG_SCHEMA( + { + "ssid": "net", + "password": "secret12", + "use_address": "test.local", + "lease_cache": lease_cache, + } + ) + + +def test_storage_defaults_to_rtc(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + config = _validate({"max_age": "12h"}) + assert config["lease_cache"]["storage"] == "rtc" + + +def test_no_rtc_variant_requires_flash(set_core_config: SetCoreConfigCallable) -> None: + """A variant without RTC memory must say storage: flash; the error points at that key.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C2} + ) + with pytest.raises(cv.Invalid, match="no RTC memory") as exc: + _validate({"max_age": "12h"}) + assert exc.value.path == ["lease_cache", "storage"] + config = _validate({"storage": "flash"}) + assert config["lease_cache"]["storage"] == "flash" diff --git a/tests/components/wifi/test-lease-cache.esp32-idf.yaml b/tests/components/wifi/test-lease-cache.esp32-idf.yaml new file mode 100644 index 0000000000..47073b9106 --- /dev/null +++ b/tests/components/wifi/test-lease-cache.esp32-idf.yaml @@ -0,0 +1,8 @@ +# DHCP lease cache with the default RTC storage and max_age. +wifi: + lease_cache: + enabled: true + max_age: 12h + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml b/tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml new file mode 100644 index 0000000000..da74ce50ae --- /dev/null +++ b/tests/components/wifi/validate-lease-cache-flash.esp32-idf.yaml @@ -0,0 +1,13 @@ +# lease_cache with storage: flash, alongside manual_ip. +wifi: + lease_cache: + enabled: true + storage: flash + manual_ip: + static_ip: 192.168.1.100 + gateway: 192.168.1.1 + subnet: 255.255.255.0 + dns1: 1.1.1.1 + +packages: + wifi: !include common.yaml From f8076fe90b166234b770b23346882147325a444a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 18:48:41 -1000 Subject: [PATCH 156/268] [core] Add UnavoidableBlockingScope for blocking that cannot be shortened (#19020) --- esphome/core/application.h | 33 ++++++ esphome/core/millis_internal.h | 1 + tests/components/core/test_blocking_scope.cpp | 103 ++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 tests/components/core/test_blocking_scope.cpp diff --git a/esphome/core/application.h b/esphome/core/application.h index b91bc187e6..f9f98a6707 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -390,6 +390,7 @@ class Application { friend Component; friend class Scheduler; friend class LoopBlockingGuard; + friend class UnavoidableBlockingScope; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -632,6 +633,38 @@ class LoopBlockingGuard { static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); }; +/// Leaves a stretch of the current loop pass out of the blocking warning. +/// +/// Only for work that cannot be made shorter or split across passes: radio +/// bring-up, a first network connect, a key generation. Work that could be +/// cached, deferred, polled or turned into a state machine stays outside, so +/// the warning keeps finding it. +/// +/// Affects only work timed by LoopBlockingGuard: loop() and scheduler +/// callbacks, not setup(). Main loop task only. The watchdog is not fed, so +/// pair long work with watchdog::WatchdogManager. Scopes may nest. +/// Afterwards App.get_loop_component_start_time() can be earlier than a +/// millis() read taken before the scope; time the work with millis() at both ends. +/// +/// UnavoidableBlockingScope scope; +/// this->generate_key_(); +class UnavoidableBlockingScope { + public: + UnavoidableBlockingScope() : started_(MillisInternal::get()), pass_start_(App.get_loop_component_start_time()) {} + ~UnavoidableBlockingScope() { + // Move the pass start seen at entry forward by the time spent here, clamped to now + const uint32_t now = MillisInternal::get(); + const uint32_t moved = this->pass_start_ + (now - this->started_); + App.set_loop_component_start_time_(static_cast(now - moved) < 0 ? now : moved); + } + UnavoidableBlockingScope(const UnavoidableBlockingScope &) = delete; + UnavoidableBlockingScope &operator=(const UnavoidableBlockingScope &) = delete; + + private: + uint32_t started_; + uint32_t pass_start_; +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index 7297d22357..c9b8454078 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -51,6 +51,7 @@ class MillisInternal { } friend class Application; friend class LoopBlockingGuard; + friend class UnavoidableBlockingScope; }; } // namespace esphome diff --git a/tests/components/core/test_blocking_scope.cpp b/tests/components/core/test_blocking_scope.cpp new file mode 100644 index 0000000000..8bdeec19ea --- /dev/null +++ b/tests/components/core/test_blocking_scope.cpp @@ -0,0 +1,103 @@ +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome { + +// The scope must push the pass start forward by the time it covers and by +// nothing else, so the blocking guard sees only the work outside it +TEST(UnavoidableBlockingScope, ExcludesItsDurationFromThePass) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + ASSERT_EQ(App.get_loop_component_start_time(), pass_start); + + const uint32_t before = millis(); + { + UnavoidableBlockingScope scope; + delay(30); + } + const uint32_t excused = millis() - before; + + const uint32_t moved = App.get_loop_component_start_time() - pass_start; + EXPECT_GE(moved, 30u); + EXPECT_LE(moved, excused); +} + +TEST(UnavoidableBlockingScope, ZeroLengthScopeLeavesTheStartAlone) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + const uint32_t before = millis(); + { UnavoidableBlockingScope scope; } + EXPECT_LE(App.get_loop_component_start_time() - pass_start, millis() - before); +} + +// Nested scopes leave out the outer span exactly once, and never move the +// start past now +TEST(UnavoidableBlockingScope, NestedScopesExcuseTheOuterSpanOnce) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + const uint32_t before = millis(); + { + UnavoidableBlockingScope outer; + { + UnavoidableBlockingScope inner; + delay(30); + } + delay(5); + } + const uint32_t excused = millis() - before; + const uint32_t moved = App.get_loop_component_start_time() - pass_start; + EXPECT_GE(moved, 35u); + EXPECT_LE(moved, excused); + EXPECT_GE(static_cast(millis() - App.get_loop_component_start_time()), 0); +} + +namespace { +// Static: the guard publishes the component to App and nothing clears it. +// One instance per test, since a ratcheted threshold is permanent +class DummyComponent : public Component {}; +DummyComponent &blocking_test_component(size_t index) { + static DummyComponent components[2]; + return components[index]; +} +} // namespace + +// The excused stretch must neither warn nor ratchet the component's threshold +TEST(UnavoidableBlockingScope, ExcusedStretchDoesNotRatchetTheThreshold) { + DummyComponent &component = blocking_test_component(0); + uint32_t threshold_before = 0; + component.should_warn_of_blocking(0, threshold_before); + { + LoopBlockingGuard guard(&component, nullptr, millis()); + { + UnavoidableBlockingScope scope; + delay(WARN_IF_BLOCKING_OVER_CS * 10U + 20); + } + guard.finish(); + } + uint32_t threshold_after = 0; + component.should_warn_of_blocking(0, threshold_after); + EXPECT_EQ(threshold_after, threshold_before); +} + +// Work outside the scope is still measured and still ratchets +TEST(UnavoidableBlockingScope, WorkOutsideTheScopeStillRatchetsTheThreshold) { + DummyComponent &component = blocking_test_component(1); + uint32_t threshold_before = 0; + component.should_warn_of_blocking(0, threshold_before); + { + LoopBlockingGuard guard(&component, nullptr, millis()); + { + UnavoidableBlockingScope scope; + delay(20); + } + delay(WARN_IF_BLOCKING_OVER_CS * 10U + 20); + guard.finish(); + } + uint32_t threshold_after = 0; + component.should_warn_of_blocking(0, threshold_after); + EXPECT_GT(threshold_after, threshold_before); +} + +} // namespace esphome From 1c1b4d13adf04c93cfb96393070e5293949d8d67 Mon Sep 17 00:00:00 2001 From: Firat Hajiyev Date: Mon, 12 Oct 2026 00:58:41 -0400 Subject: [PATCH 157/268] [core] Mask integer-valued credentials without changing code generation (#20430) --- .../components/fingerprint_grow/__init__.py | 4 +- esphome/components/openthread/__init__.py | 8 ++- esphome/config_validation.py | 22 +++++-- esphome/yaml_util.py | 34 ++++++++--- .../fingerprint_grow/__init__.py | 1 + .../fingerprint_grow/test_init.py | 19 ++++++ tests/component_tests/openthread/__init__.py | 1 + tests/component_tests/openthread/test_init.py | 39 ++++++++++++ tests/unit_tests/test_config_validation.py | 47 +++++++++++---- tests/unit_tests/test_yaml_util.py | 60 ++++++++++++++----- 10 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 tests/component_tests/fingerprint_grow/__init__.py create mode 100644 tests/component_tests/fingerprint_grow/test_init.py create mode 100644 tests/component_tests/openthread/__init__.py create mode 100644 tests/component_tests/openthread/test_init.py diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 0e522ced25..e496e9c635 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -81,8 +81,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_IDLE_PERIOD_TO_SLEEP ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_PASSWORD): cv.uint32_t, - cv.Optional(CONF_NEW_PASSWORD): cv.uint32_t, + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.uint32_t), + cv.Optional(CONF_NEW_PASSWORD): cv.sensitive(cv.uint32_t), cv.Optional(CONF_ON_FINGER_SCAN_START): automation.validate_automation({}), cv.Optional(CONF_ON_FINGER_SCAN_MATCHED): automation.validate_automation( {} diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 25c7a78beb..6fca8bfdd9 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -166,10 +166,10 @@ _CONNECTION_SCHEMA = cv.Schema( { cv.Optional(CONF_PAN_ID): cv.hex_int, cv.Optional(CONF_CHANNEL): cv.int_range(min=11, max=26), - cv.Optional(CONF_NETWORK_KEY): cv.hex_int, + cv.Optional(CONF_NETWORK_KEY): cv.sensitive(cv.hex_int), cv.Optional(CONF_EXT_PAN_ID): cv.hex_int, cv.Optional(CONF_NETWORK_NAME): cv.string_strict, - cv.Optional(CONF_PSKC): cv.hex_int, + cv.Optional(CONF_PSKC): cv.sensitive(cv.hex_int), cv.Optional(CONF_MESH_LOCAL_PREFIX): cv.ipv6network, } ) @@ -243,7 +243,9 @@ CONFIG_SCHEMA = cv.All( *CONF_DEVICE_TYPES, upper=True ), cv.Optional(CONF_FORCE_DATASET): cv.boolean, - cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), + cv.Optional(CONF_TLV): cv.sensitive( + cv.All(cv.string_strict, _validate_tlv_hex) + ), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_OUTPUT_POWER): cv.All( cv.decibel, diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 64d22f9a14..e7d2501e5d 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -114,7 +114,13 @@ from esphome.schema_extractors import ( # pylint: disable-next=unused-import from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import KeyInvalid, _Schema -from esphome.yaml_util import SensitiveStr, make_data_base +from esphome.yaml_util import ( + Sensitive, + SensitiveHexInt, + SensitiveInt, + SensitiveStr, + make_data_base, +) if typing.TYPE_CHECKING: from esphome.types import ConfigType @@ -562,11 +568,17 @@ class SensitiveValidator: def __call__(self, value: typing.Any) -> typing.Any: validated = self.inner(value) - # Tag string results so yaml_util.dump can mask them. Non-string - # results pass through unchanged; already-tagged values are not - # re-wrapped to keep nested cv.sensitive applications idempotent. - if isinstance(validated, str) and not isinstance(validated, SensitiveStr): + # Already-tagged values are returned as-is so nested cv.sensitive is + # idempotent; bool is an int subclass and must stay bool. Other types + # (e.g. lambdas) pass through unmasked. + if isinstance(validated, (Sensitive, bool)): + return validated + if isinstance(validated, str): return SensitiveStr(validated) + if isinstance(validated, int): + return (SensitiveHexInt if isinstance(validated, HexInt) else SensitiveInt)( + validated + ) return validated def __repr__(self) -> str: diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index e892664724..f550bb543b 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -81,7 +81,13 @@ def _raise_invalid(message: str) -> None: raise Invalid(message) -class SensitiveStr(str): +class Sensitive: + """Marker base for validated values masked in user-visible YAML output.""" + + __slots__ = () + + +class SensitiveStr(Sensitive, str): """Marker subclass for validated strings that should be masked in user-visible YAML output. ``cv.sensitive`` wraps validated values in this type so ``dump()`` can render them with ANSI conceal codes without @@ -91,6 +97,16 @@ class SensitiveStr(str): __slots__ = () +class SensitiveInt(Sensitive, int): + """Marker subclass for integer credentials masked in user-visible YAML.""" + + __slots__ = () + + +class SensitiveHexInt(core.HexInt, SensitiveInt): + """Keep hexadecimal formatting and code generation for sensitive integers.""" + + @contextmanager def track_yaml_loads() -> Generator[list[Path]]: """Context manager that records every file loaded by the YAML loader. @@ -1361,11 +1377,11 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_stringify(path.as_posix()) return self.represent_stringify(value) - def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: + def represent_sensitive(self, value: Sensitive) -> yaml.ScalarNode: # Only the redact-and-not-a-secret branch is unique to sensitive - # values; otherwise let ``represent_stringify`` handle ``!secret`` - # precedence and the plain-str fallthrough. Conceal sequence is - # emitted as literal ``\033`` text (not actual ESC bytes) so the + # values; otherwise use the plain string/integer representer to + # preserve ``!secret`` references and scalar types. Conceal sequence + # is emitted as literal ``\033`` text (not actual ESC bytes) so the # output matches the prior regex format and device-builder's # ``\033[8m...\033[28m`` parser keeps working. if self._redact_sensitive and not is_secret(value): @@ -1373,6 +1389,10 @@ class ESPHomeDumper(yaml.SafeDumper): tag="tag:yaml.org,2002:str", value=f"\\033[8m{value}\\033[28m", ) + # MRO dispatch picks this representer over represent_int, so + # integers have to be forwarded explicitly. + if isinstance(value, int): + return self.represent_int(value) return self.represent_stringify(value) # pylint: disable=arguments-renamed @@ -1452,8 +1472,8 @@ ESPHomeDumper.add_multi_representer( ) ESPHomeDumper.add_multi_representer(bool, ESPHomeDumper.represent_bool) ESPHomeDumper.add_multi_representer(str, ESPHomeDumper.represent_stringify) -# MRO-walked dispatch; SensitiveStr's own entry wins over the str one. -ESPHomeDumper.add_multi_representer(SensitiveStr, ESPHomeDumper.represent_sensitive) +# MRO-walked dispatch; Sensitive precedes str and int in every marker's MRO. +ESPHomeDumper.add_multi_representer(Sensitive, ESPHomeDumper.represent_sensitive) ESPHomeDumper.add_multi_representer(int, ESPHomeDumper.represent_int) ESPHomeDumper.add_multi_representer(float, ESPHomeDumper.represent_float) ESPHomeDumper.add_multi_representer(_BaseAddress, ESPHomeDumper.represent_stringify) diff --git a/tests/component_tests/fingerprint_grow/__init__.py b/tests/component_tests/fingerprint_grow/__init__.py new file mode 100644 index 0000000000..42c5511598 --- /dev/null +++ b/tests/component_tests/fingerprint_grow/__init__.py @@ -0,0 +1 @@ +"""Tests for the fingerprint_grow component.""" diff --git a/tests/component_tests/fingerprint_grow/test_init.py b/tests/component_tests/fingerprint_grow/test_init.py new file mode 100644 index 0000000000..3df54f8847 --- /dev/null +++ b/tests/component_tests/fingerprint_grow/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the fingerprint_grow component schema.""" + +import pytest + +from esphome.components.fingerprint_grow import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.yaml_util import Sensitive +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize("field", ["password", "new_password"]) +def test_password_is_sensitive( + field: str, set_core_config: SetCoreConfigCallable +) -> None: + """Numeric sensor passwords are tagged for redaction and keep their value.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({field: 305419896}) + assert isinstance(config[field], Sensitive) + assert config[field] == 305419896 diff --git a/tests/component_tests/openthread/__init__.py b/tests/component_tests/openthread/__init__.py new file mode 100644 index 0000000000..d786022c12 --- /dev/null +++ b/tests/component_tests/openthread/__init__.py @@ -0,0 +1 @@ +"""Tests for the openthread component.""" diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py new file mode 100644 index 0000000000..7ba9bdd4bb --- /dev/null +++ b/tests/component_tests/openthread/test_init.py @@ -0,0 +1,39 @@ +"""Tests for the openthread component schema.""" + +import pytest + +from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32C6 +from esphome.components.openthread import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.core import HexInt +from esphome.yaml_util import Sensitive +from tests.component_tests.types import SetCoreConfigCallable + +NETWORK_KEY = 0xDFD34F0F05CAD978EC4E32B0413038FF +PSKC = 0xC23A76E98F1A6483639B1AC1271E2E27 +TLV = "0e080000000000010000000300001435060004001fffe0" + + +@pytest.mark.parametrize( + ("config", "field", "expected"), + [ + ({"network_key": hex(NETWORK_KEY)}, "network_key", NETWORK_KEY), + ({"network_key": hex(NETWORK_KEY), "pskc": hex(PSKC)}, "pskc", PSKC), + ({"tlv": TLV}, "tlv", TLV), + ], +) +def test_credentials_are_sensitive( + config: dict[str, str], + field: str, + expected: int | str, + set_core_config: SetCoreConfigCallable, +) -> None: + """Thread credentials are tagged for redaction without changing their value + or the hexadecimal integer type used to build sdkconfig options.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + validated = CONFIG_SCHEMA(config) + assert isinstance(validated[field], Sensitive) + assert validated[field] == expected + assert isinstance(validated[field], HexInt) == isinstance(expected, int) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 742f07c8de..0f68ccb34a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import importlib import io import json @@ -11,7 +12,7 @@ from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol -from esphome import config_validation as cv +from esphome import config_validation as cv, cpp_generator from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, @@ -69,6 +70,8 @@ from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.util import Registry from esphome.yaml_util import ( ESPHomeDataBase, + SensitiveHexInt, + SensitiveInt, SensitiveStr, load_yaml, make_data_base, @@ -201,12 +204,13 @@ def test_sensitive__wraps_string_result_in_sensitive_str() -> None: assert result == "hunter2" -def test_sensitive__does_not_double_tag_already_sensitive() -> None: - # If the inner validator already returns a SensitiveStr (e.g., nested +@pytest.mark.parametrize( + "pre_tagged", [SensitiveStr("hunter2"), SensitiveInt(5), SensitiveHexInt(0x12)] +) +def test_sensitive__does_not_double_tag_already_sensitive(pre_tagged: object) -> None: + # If the inner validator already returns a tagged value (e.g., nested # cv.sensitive wrappers), re-tagging is a no-op rather than a new - # SensitiveStr around the same value. - pre_tagged = SensitiveStr("hunter2") - + # wrapper around the same value. def inner(_value): return pre_tagged @@ -216,16 +220,33 @@ def test_sensitive__does_not_double_tag_already_sensitive() -> None: assert result is pre_tagged -def test_sensitive__non_string_result_passes_through() -> None: - # If an inner validator returns something other than a string (e.g., a - # Lambda template), the sensitive wrapper must not coerce it. - sentinel = object() - +@pytest.mark.parametrize("passthrough", [object(), True, False]) +def test_sensitive__non_string_result_passes_through(passthrough: object) -> None: + # If an inner validator returns something other than a string or integer + # (e.g., a Lambda template or a bool), the sensitive wrapper must not + # coerce it. def inner(_value): - return sentinel + return passthrough validator = cv.sensitive(inner) - assert validator("anything") is sentinel + assert validator("anything") is passthrough + + +@pytest.mark.parametrize("inner", [cv.uint32_t, cv.hex_int]) +def test_sensitive__integer_preserves_codegen(inner: Callable[[str], int]) -> None: + """Tag credentials without changing their firmware expressions.""" + original = inner("0x12345678") + tagged = cv.sensitive(inner)("0x12345678") + assert isinstance(tagged, SensitiveInt) + assert tagged == original + assert str(cpp_generator.safe_exp(tagged)) == str(cpp_generator.safe_exp(original)) + assert isinstance(tagged, HexInt) == isinstance(original, HexInt) + + +def test_sensitive__integer_validation_still_rejects_out_of_range() -> None: + """Keep the inner validator's range checks.""" + with pytest.raises(Invalid): + cv.sensitive(cv.uint32_t)(1 << 32) def test_sensitive__is_detectable_via_isinstance() -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e1c3080e29..60b05b0b2b 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1,3 +1,4 @@ +from collections.abc import Callable import io from pathlib import Path import shutil @@ -1901,9 +1902,39 @@ def test_dump__redacts_sensitive_str_by_default() -> None: ) # the raw value is only present inside the wrap -def test_dump__show_secrets_emits_sensitive_str_raw() -> None: - out = yaml_util.dump({"password": SensitiveStr("hunter2")}, show_secrets=True) - assert "hunter2" in out +@pytest.mark.parametrize( + ("validator", "value", "rendered"), + [ + (cv.uint32_t, 305419896, "305419896"), + ( + cv.hex_int, + 0x00112233445566778899AABBCCDDEEFF, + "0x112233445566778899AABBCCDDEEFF", + ), + (cv.int_, -42, "-42"), + ], +) +def test_dump__redacts_sensitive_integer( + validator: Callable[[int], int], value: int, rendered: str +) -> None: + """Mask integer credentials without changing their numeric value or format.""" + sensitive = cv.sensitive(validator)(value) + assert sensitive == value + assert str(sensitive) == rendered + out = yaml_util.dump({"credential": sensitive}) + assert f"\\033[8m{rendered}\\033[28m" in out + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (SensitiveStr("hunter2"), "password: hunter2"), + (cv.sensitive(cv.hex_int)(0x12345678), "password: 0x12345678"), + ], +) +def test_dump__show_secrets_emits_sensitive_raw(value: object, expected: str) -> None: + out = yaml_util.dump({"password": value}, show_secrets=True) + assert out.strip() == expected assert "\\033[8m" not in out assert "\\033[28m" not in out @@ -1914,19 +1945,20 @@ def test_dump__plain_str_is_not_redacted() -> None: assert "\\033[8m" not in out -def test_dump__secret_reference_wins_over_redaction() -> None: +@pytest.mark.parametrize( + "value", [SensitiveStr("hunter2"), cv.sensitive(cv.uint32_t)(305419896)] +) +def test_dump__secret_reference_wins_over_redaction( + value: object, monkeypatch: pytest.MonkeyPatch +) -> None: # If the value also has an entry in _SECRET_VALUES (i.e., it was loaded # via !secret), the dump should render it as !secret , not as a - # redacted scalar. SensitiveStr layered on top must not change that. - value = SensitiveStr("hunter2") - yaml_util._SECRET_VALUES[str(value)] = "my_secret_name" - try: - out = yaml_util.dump({"password": value}) - assert "!secret" in out - assert "my_secret_name" in out - assert "\\033[8m" not in out - finally: - yaml_util._SECRET_VALUES.clear() + # redacted scalar. The sensitive marker layered on top must not change that. + monkeypatch.setitem(yaml_util._SECRET_VALUES, str(value), "my_secret_name") + out = yaml_util.dump({"password": value}) + assert "!secret" in out + assert "my_secret_name" in out + assert "\\033[8m" not in out def test_dump__redaction_flag_does_not_leak_between_calls() -> None: From 29db7b3f51b80cb794a9844d092664e9b7cd6268 Mon Sep 17 00:00:00 2001 From: Ivan Kruglov <4630547+ikruglov@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:33:44 +0200 Subject: [PATCH 158/268] [bl0939] Add optional internal temperature sensor (#19415) Co-authored-by: J. Nick Koston --- esphome/components/bl0939/bl0939.cpp | 8 ++++++++ esphome/components/bl0939/bl0939.h | 2 ++ esphome/components/bl0939/sensor.py | 12 ++++++++++++ tests/components/bl0939/common.yaml | 2 ++ 4 files changed, 24 insertions(+) diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index 0e2da59ab1..3a34fad140 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -116,6 +116,13 @@ void BL0939::received_package_(const DataPacket *data) const { energy_sensor_sum_->publish_state(total_energy_consumption); } + if (this->internal_temperature_sensor_ != nullptr) { + const uint16_t raw_temperature = ((uint16_t(data->tps1.h) << 8) | data->tps1.l) & 0x3FF; + // Datasheet temperature conversion: Tx = (170 / 448) * (TB / 2 - 32) - 45. + const float temperature = (170.0f / 448.0f) * (raw_temperature / 2.0f - 32.0f) - 45.0f; + this->internal_temperature_sensor_->publish_state(temperature); + } + ESP_LOGV(TAG, "BL0939: U %fV, I1 %fA, I2 %fA, P1 %fW, P2 %fW, CntA %" PRId32 ", CntB %" PRId32 ", ∫P1 %fkWh, ∫P2 %fkWh", v_rms, ia_rms, ib_rms, a_watt, b_watt, cfa_cnt, cfb_cnt, a_energy_consumption, b_energy_consumption); @@ -131,6 +138,7 @@ void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexit LOG_SENSOR("", "Energy 1", this->energy_sensor_1_); LOG_SENSOR("", "Energy 2", this->energy_sensor_2_); LOG_SENSOR("", "Energy sum", this->energy_sensor_sum_); + LOG_SENSOR("", "Internal Temperature", this->internal_temperature_sensor_); } uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index 333bca3715..a22d96ad40 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -66,6 +66,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { void set_energy_sensor_1(sensor::Sensor *energy_sensor_1) { energy_sensor_1_ = energy_sensor_1; } void set_energy_sensor_2(sensor::Sensor *energy_sensor_2) { energy_sensor_2_ = energy_sensor_2; } void set_energy_sensor_sum(sensor::Sensor *energy_sensor_sum) { energy_sensor_sum_ = energy_sensor_sum; } + void set_internal_temperature_sensor(sensor::Sensor *sensor) { this->internal_temperature_sensor_ = sensor; } void loop() override; @@ -84,6 +85,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { sensor::Sensor *energy_sensor_1_{nullptr}; sensor::Sensor *energy_sensor_2_{nullptr}; sensor::Sensor *energy_sensor_sum_{nullptr}; + sensor::Sensor *internal_temperature_sensor_{nullptr}; // Divide by this to turn into Watt float power_reference_ = BL0939_PREF; diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 9f6bd7814e..401fafedd5 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -3,14 +3,18 @@ from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_INTERNAL_TEMPERATURE, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, + UNIT_CELSIUS, UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_WATT, @@ -82,6 +86,13 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional(CONF_INTERNAL_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), } ) .extend(cv.polling_component_schema("60s")) @@ -103,3 +114,4 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_ENERGY_1, var.set_energy_sensor_1) await sensors(CONF_ENERGY_2, var.set_energy_sensor_2) await sensors(CONF_ENERGY_TOTAL, var.set_energy_sensor_sum) + await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor) diff --git a/tests/components/bl0939/common.yaml b/tests/components/bl0939/common.yaml index a47aa05606..0785fae029 100644 --- a/tests/components/bl0939/common.yaml +++ b/tests/components/bl0939/common.yaml @@ -16,3 +16,5 @@ sensor: name: BL0939 Energy 2 energy_total: name: BL0939 Total energy + internal_temperature: + name: BL0939 Internal Temperature From 2692e0f189bb56b2f28cd9312f234fc4b84b1083 Mon Sep 17 00:00:00 2001 From: Phil Date: Mon, 12 Oct 2026 06:36:07 +0100 Subject: [PATCH 159/268] [remote_base] Add Nexus RF protocol support (#13577) Co-authored-by: zry98 Co-authored-by: Citizen Co-authored-by: Citizen07 <34106434+Citizen07@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Signed-off-by: zry98 --- esphome/components/remote_base/__init__.py | 60 +++++++++++++ .../components/remote_base/nexus_protocol.cpp | 83 ++++++++++++++++++ .../components/remote_base/nexus_protocol.h | 30 +++++++ tests/components/remote_base/__init__.py | 2 +- tests/components/remote_base/nexus_test.cpp | 84 +++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 ++ .../remote_receiver/esp32-common.yaml | 5 ++ .../remote_transmitter/common-buttons.yaml | 10 +++ 8 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_base/nexus_protocol.cpp create mode 100644 esphome/components/remote_base/nexus_protocol.h create mode 100644 tests/components/remote_base/nexus_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 01e25714e7..af043c0ea8 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -9,6 +9,7 @@ from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, + CONF_BATTERY_LEVEL, CONF_BUTTON, CONF_CARRIER_FREQUENCY, CONF_CHANNEL, @@ -20,7 +21,9 @@ from esphome.const import ( CONF_DELTA, CONF_DEVICE, CONF_FAMILY, + CONF_FORCE_UPDATE, CONF_GROUP, + CONF_HUMIDITY, CONF_ID, CONF_INDEX, CONF_INVERTED, @@ -37,6 +40,7 @@ from esphome.const import ( CONF_SOURCE, CONF_STATE, CONF_SYNC, + CONF_TEMPERATURE, CONF_TIMES, CONF_TRIGGER_ID, CONF_TYPE_ID, @@ -2161,6 +2165,62 @@ register_protocol_action( ) +# Nexus +NexusData, NexusBinarySensor, NexusTrigger, _, NexusDumper = declare_protocol("Nexus") +NEXUS_SCHEMA = cv.Schema( + { + cv.Required(CONF_CHANNEL): cv.int_range(min=1, max=4), + cv.Required(CONF_ADDRESS): cv.hex_uint8_t, + } +) +NEXUS_ACTION_SCHEMA = NEXUS_SCHEMA.extend( + { + cv.Required(CONF_TEMPERATURE): cv.All( + cv.float_, cv.Range(min=-204.8, max=204.7) + ), + cv.Required(CONF_HUMIDITY): cv.uint8_t, + cv.Optional(CONF_BATTERY_LEVEL, default=True): cv.boolean, + cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, + } +) + + +@register_binary_sensor("nexus", NexusBinarySensor, NEXUS_SCHEMA) +def nexus_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + NexusData, + ("address", config[CONF_ADDRESS]), + ("channel", config[CONF_CHANNEL]), + ) + ) + ) + + +@register_trigger("nexus", NexusTrigger, NexusData) +def nexus_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("nexus", NexusDumper) +def nexus_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "nexus", + "Nexus", + NEXUS_ACTION_SCHEMA, + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_TEMPERATURE, "temperature", cg.float_), + (CONF_HUMIDITY, "humidity", cg.uint8), + (CONF_BATTERY_LEVEL, "battery_level", cg.bool_), + (CONF_FORCE_UPDATE, "force_update", cg.bool_), +) + + # Midea MideaData, MideaBinarySensor, MideaTrigger, MideaAction, MideaDumper = declare_protocol( "Midea" diff --git a/esphome/components/remote_base/nexus_protocol.cpp b/esphome/components/remote_base/nexus_protocol.cpp new file mode 100644 index 0000000000..6d7ddbb7eb --- /dev/null +++ b/esphome/components/remote_base/nexus_protocol.cpp @@ -0,0 +1,83 @@ +#include "nexus_protocol.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.nexus"); + +/** + * Example Nexus (Digoo) temperature & humidity sensor packet (36 data bits, each a mark and a space): + * 10000101 | 1 | 0 | 01 | 000010001010 | 1111 | 01000101 + * address (sensor ID) | battery | force update | channel | temperature | - | humidity + * + * The channel is sent 0-based and shown 1-based, temperature is 12 bit signed scaled by 10, + * `-`s are constant bits. + * + * Ref: https://manual.pilight.org/protocols/433.92/weather/nexus.html + */ + +static constexpr uint8_t NBITS = 36; + +static constexpr uint32_t SYNC_HIGH_US = 500; +static constexpr uint32_t SYNC_LOW_US = 3900; +static constexpr uint32_t BIT_HIGH_US = 500; +static constexpr uint32_t BIT_ZERO_LOW_US = 980; +static constexpr uint32_t BIT_ONE_LOW_US = 1950; + +void NexusProtocol::encode(RemoteTransmitData *dst, const NexusData &data) { + // A templated value may be out of range or NaN (an unavailable sensor); send 0 for NaN + const float temperature = std::isnan(data.temperature) ? 0.0f : std::clamp(data.temperature, -204.8f, 204.7f); + const auto t = static_cast(lroundf(temperature * 10.0f)); + const uint64_t packet = uint64_t(data.address) << 28 | uint64_t(data.battery_level) << 27 | + uint64_t(data.force_update) << 26 | uint64_t((data.channel - 1) & 0b11) << 24 | + uint64_t(t & 0x0FFF) << 12 | 0b1111 << 8 | data.humidity; + + dst->reserve(NBITS * 2 + 2); + for (int8_t i = NBITS - 1; i >= 0; i--) + dst->item(BIT_HIGH_US, (packet >> i) & 1 ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + // The sync follows the bits, as the sensors send it: its mark ends the last space, its gap separates repeats + dst->item(SYNC_HIGH_US, SYNC_LOW_US); +} + +optional NexusProtocol::decode(RemoteReceiveData src) { + // A buffer may start with the previous frame's sync when the receiver did not split on it + src.expect_item(SYNC_HIGH_US, SYNC_LOW_US); + if (src.size() < NBITS * 2) + return {}; + + uint64_t packet = 0; + for (uint8_t i = 0; i < NBITS; i++) { + packet <<= 1; + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + packet |= 1; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return {}; + } + } + if (((packet >> 8) & 0xF) != 0xF) + return {}; + ESP_LOGV(TAG, "Nexus packet: 0x%09" PRIX64, packet); + + return NexusData{ + // The 12 bit field sits at bits 12-23; shift it to the top of an int16_t and back to sign extend. + .temperature = (static_cast((packet >> 8) & 0xFFF0) >> 4) / 10.0f, + .address = static_cast(packet >> 28), + .battery_level = static_cast((packet >> 27) & 1), + .force_update = static_cast((packet >> 26) & 1), + .channel = static_cast(((packet >> 24) & 0b11) + 1), + .humidity = static_cast(packet), + }; +} + +void NexusProtocol::dump(const NexusData &data) { + ESP_LOGI(TAG, + "Received Nexus: address=0x%02X, channel=%u, temperature=%.1f, humidity=%u, battery_level=%d, " + "force_update=%d", + data.address, data.channel, data.temperature, data.humidity, data.battery_level, data.force_update); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nexus_protocol.h b/esphome/components/remote_base/nexus_protocol.h new file mode 100644 index 0000000000..de3f55777d --- /dev/null +++ b/esphome/components/remote_base/nexus_protocol.h @@ -0,0 +1,30 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct NexusData { + float temperature; // °C, sent as 12 bit signed tenths + uint8_t address; + bool battery_level; + bool force_update; + uint8_t channel; // sent 0-based, shown 1-based + uint8_t humidity; + + bool operator==(const NexusData &rhs) const { + // The binary sensor matches the transmitter, not the reading. + return channel == rhs.channel && address == rhs.address; + } +}; + +class NexusProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const NexusData &data); + optional decode(RemoteReceiveData src); + void dump(const NexusData &data); +}; + +DECLARE_REMOTE_PROTOCOL(Nexus) + +} // namespace esphome::remote_base diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 90734b72f2..572505b01a 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", "keeloq"): + for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq", "nexus"): request_protocol(protocol) manifest.to_code = to_code_testing diff --git a/tests/components/remote_base/nexus_test.cpp b/tests/components/remote_base/nexus_test.cpp new file mode 100644 index 0000000000..b29bd00943 --- /dev/null +++ b/tests/components/remote_base/nexus_test.cpp @@ -0,0 +1,84 @@ +#include + +#include +#include "esphome/components/remote_base/nexus_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +RawTimings encode(const NexusData &data) { + RemoteTransmitData tx; + NexusProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return NexusProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_round_trip(float temperature, bool drop_sync) { + const NexusData sent{.temperature = temperature, + .address = 0x85, + .battery_level = true, + .force_update = false, + .channel = 2, + .humidity = 69}; + RawTimings timings = encode(sent); + ASSERT_EQ(timings.size(), 2u + 36u * 2u); + if (drop_sync) { + timings.resize(timings.size() - 2); + } else { + // The receiver kept the previous frame's sync in front of this one + timings.insert(timings.begin(), timings.end() - 2, timings.end()); + } + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()) << temperature; + if (!decoded.has_value()) { + return; + } + EXPECT_FLOAT_EQ(decoded->temperature, temperature); + EXPECT_EQ(decoded->address, 0x85); + EXPECT_TRUE(decoded->battery_level); + EXPECT_FALSE(decoded->force_update); + EXPECT_EQ(decoded->channel, 2); + EXPECT_EQ(decoded->humidity, 69); +} + +} // namespace + +TEST(NexusProtocolTest, RoundTripsWithLeadingSync) { + expect_round_trip(13.8f, false); + expect_round_trip(-12.3f, false); + expect_round_trip(-204.8f, false); + expect_round_trip(204.7f, false); +} + +TEST(NexusProtocolTest, RoundTripsWithoutTrailingSync) { + expect_round_trip(0.0f, true); + expect_round_trip(-0.1f, true); +} + +TEST(NexusProtocolTest, ClampsOutOfRangeTemperature) { + auto decoded = decode(encode(NexusData{.temperature = 300.0f, .channel = 1})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_FLOAT_EQ(decoded->temperature, 204.7f); + decoded = decode(encode(NexusData{.temperature = NAN, .channel = 1})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_FLOAT_EQ(decoded->temperature, 0.0f); +} + +TEST(NexusProtocolTest, RejectsWrongConstantBits) { + RawTimings timings = encode(NexusData{.temperature = 20.0f, .channel = 1}); + // Constant bits are data bits 24-27; each bit is a mark and a space, the sync comes last. + timings[24 * 2 + 1] = -980; + EXPECT_FALSE(decode(timings).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 55f489505c..9eb21f30fc 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -103,6 +103,11 @@ on_nexa: - logger.log: format: "on_nexa: %lu %u %u %u %u" args: ["long(x.device)", "x.group", "x.state", "x.channel", "x.level"] +on_nexus: + then: + - logger.log: + format: "on_nexus: %u %u %.1f %u %d %d" + args: ["x.channel", "x.address", "x.temperature", "x.humidity", "x.battery_level", "x.force_update"] on_onkyori: then: - logger.log: diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index 956cfdc323..ad5eb72b04 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -19,3 +19,8 @@ binary_sensor: name: Hob2Hood Light On hob2hood: command: light_on + - platform: remote_receiver + name: Nexus Sensor + nexus: + channel: 1 + address: 0x42 diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 261f78b262..c81a8687d6 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -52,6 +52,16 @@ button: remote_transmitter.transmit_nec: address: 0x4242 command: 0x8484 + - platform: template + name: Nexus + on_press: + remote_transmitter.transmit_nexus: + channel: 1 + address: 0x42 + temperature: -12.3 + humidity: 69 + battery_level: false + force_update: true - platform: template name: LG on_press: From 8191cd18d27678705881522313153bec19a8fe9e Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:02:07 +0000 Subject: [PATCH 160/268] [ci] Refresh integration test durations (#20578) --- .../integration_test_durations.json | 345 +++++++++--------- 1 file changed, 178 insertions(+), 167 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 060799862f..75bf0b4842 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,169 +1,180 @@ { - "tests/integration/test_action_concurrent_reentry.py": 12.49, - "tests/integration/test_addressable_light_transition.py": 17.27, - "tests/integration/test_alarm_control_panel_state_transitions.py": 15.05, - "tests/integration/test_api_action_metadata.py": 10.78, - "tests/integration/test_api_action_responses.py": 17.68, - "tests/integration/test_api_action_timeout.py": 17.48, - "tests/integration/test_api_conditional_memory.py": 11.82, - "tests/integration/test_api_custom_services.py": 8.17, - "tests/integration/test_api_decode_wire_types.py": 17.82, - "tests/integration/test_api_empty_message_roundtrip.py": 12.6, - "tests/integration/test_api_encode_boundaries.py": 14.83, - "tests/integration/test_api_get_time_response_timezone.py": 12.93, - "tests/integration/test_api_homeassistant.py": 19.79, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 12.45, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 7.67, - "tests/integration/test_api_list_entities_backpressure.py": 12.69, - "tests/integration/test_api_message_size_batching.py": 15.39, - "tests/integration/test_api_reboot_timeout.py": 12.25, - "tests/integration/test_api_string_lambda.py": 13.84, - "tests/integration/test_api_vv_logging.py": 12.98, - "tests/integration/test_api_zero_psk_provisioning.py": 19.36, - "tests/integration/test_areas_and_devices.py": 8.09, - "tests/integration/test_automation_wait_actions.py": 14.26, - "tests/integration/test_automations.py": 20.27, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.02, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 14.18, - "tests/integration/test_binary_sensor_invalidate_state.py": 13.04, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 10.44, - "tests/integration/test_build_info.py": 12.48, - "tests/integration/test_camera_mock.py": 12.53, - "tests/integration/test_climate_control_action.py": 9.7, - "tests/integration/test_climate_custom_modes.py": 14.68, - "tests/integration/test_continuation_actions.py": 12.16, - "tests/integration/test_cover_control_action.py": 11.76, - "tests/integration/test_crc8_helper.py": 8.21, - "tests/integration/test_device_id_in_state.py": 26.48, - "tests/integration/test_duplicate_entities.py": 14.89, - "tests/integration/test_entity_icon.py": 15.75, - "tests/integration/test_entity_missing_state.py": 9.8, - "tests/integration/test_fan_turn_on_action.py": 12.96, - "tests/integration/test_fnv1_hash_object_id.py": 2.55, - "tests/integration/test_fnv1a_hash.py": 9.37, - "tests/integration/test_gpio_expander_cache.py": 12.31, - "tests/integration/test_host_logger_thread_safety.py": 7.8, - "tests/integration/test_host_mode_basic.py": 11.59, - "tests/integration/test_host_mode_batch_delay.py": 8.18, - "tests/integration/test_host_mode_climate_basic_state.py": 12.53, - "tests/integration/test_host_mode_climate_control.py": 14.3, - "tests/integration/test_host_mode_empty_string_options.py": 13.74, - "tests/integration/test_host_mode_entity_fields.py": 15.51, - "tests/integration/test_host_mode_fan_preset.py": 9.63, - "tests/integration/test_host_mode_many_entities.py": 16.22, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 13.26, - "tests/integration/test_host_mode_noise_encryption.py": 29.86, - "tests/integration/test_host_mode_reconnect.py": 11.7, - "tests/integration/test_host_mode_sensor.py": 11.74, - "tests/integration/test_host_ota.py": 129.75, - "tests/integration/test_host_preferences.py": 8.46, - "tests/integration/test_host_preferences_suspend_resume.py": 14.3, - "tests/integration/test_improv_serial_uart.py": 15.6, - "tests/integration/test_large_message_batching.py": 13.6, - "tests/integration/test_legacy_area.py": 11.44, - "tests/integration/test_legacy_climate_compat.py": 12.99, - "tests/integration/test_legacy_fan_compat.py": 8.75, - "tests/integration/test_light_automations.py": 15.38, - "tests/integration/test_light_binary_effect_off_phase.py": 23.85, - "tests/integration/test_light_calls.py": 16.45, - "tests/integration/test_light_constant_brightness.py": 16.87, - "tests/integration/test_light_control_action.py": 16.25, - "tests/integration/test_light_dim_relative_action.py": 14.03, - "tests/integration/test_light_effect_zero_brightness.py": 16.53, - "tests/integration/test_light_initial_state.py": 15.16, - "tests/integration/test_light_repeat_effect.py": 22.35, - "tests/integration/test_light_restore_state.py": 11.68, - "tests/integration/test_light_resume_effect.py": 17.07, - "tests/integration/test_light_toggle_action.py": 14.35, - "tests/integration/test_light_transition_state_publish_interval.py": 47.18, - "tests/integration/test_lock_automations.py": 12.65, - "tests/integration/test_logger_buffered_recursion_guard.py": 12.2, - "tests/integration/test_loop_disable_enable.py": 11.31, - "tests/integration/test_loop_interval_decoupling.py": 13.71, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 11.78, - "tests/integration/test_lvgl_headless_render.py": 49.4, - "tests/integration/test_micros_to_millis.py": 6.71, - "tests/integration/test_multi_click_trigger.py": 10.38, - "tests/integration/test_multi_device_preferences.py": 16.06, - "tests/integration/test_noise_encryption_key_protection.py": 13.43, - "tests/integration/test_object_id_api_verification.py": 14.32, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.57, - "tests/integration/test_object_id_no_friendly_name.py": 15.59, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 24.62, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 29.88, - "tests/integration/test_online_image_bmp.py": 23.67, - "tests/integration/test_oversized_payloads.py": 51.21, - "tests/integration/test_preference_key_stability.py": 14.85, - "tests/integration/test_runtime_stats.py": 11.2, - "tests/integration/test_safe_mode_loop_runs.py": 8.22, - "tests/integration/test_scheduler_blocking_warning.py": 28.18, - "tests/integration/test_scheduler_bulk_cleanup.py": 12.49, - "tests/integration/test_scheduler_defer_cancel.py": 9.95, - "tests/integration/test_scheduler_defer_cancel_regular.py": 13.32, - "tests/integration/test_scheduler_defer_fifo_simple.py": 13.18, - "tests/integration/test_scheduler_defer_stress.py": 14.29, - "tests/integration/test_scheduler_heap_stress.py": 4.73, - "tests/integration/test_scheduler_internal_id_no_collision.py": 14.05, - "tests/integration/test_scheduler_interval_reschedule.py": 9.0, - "tests/integration/test_scheduler_interval_zero_coerced.py": 3.27, - "tests/integration/test_scheduler_null_name.py": 12.94, - "tests/integration/test_scheduler_numeric_id_test.py": 11.83, - "tests/integration/test_scheduler_pool.py": 9.66, - "tests/integration/test_scheduler_rapid_cancellation.py": 3.95, - "tests/integration/test_scheduler_recursive_timeout.py": 12.91, - "tests/integration/test_scheduler_removed_item_race.py": 11.9, - "tests/integration/test_scheduler_self_keyed.py": 13.42, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 8.01, - "tests/integration/test_scheduler_string_test.py": 10.2, - "tests/integration/test_script_array_params.py": 12.99, - "tests/integration/test_script_delay_params.py": 14.73, - "tests/integration/test_script_queued.py": 16.62, - "tests/integration/test_script_queued_idle_loop.py": 11.13, - "tests/integration/test_script_wait_on_boot.py": 13.31, - "tests/integration/test_sdl_headless_screenshot.py": 14.76, - "tests/integration/test_select_stringref_trigger.py": 10.98, - "tests/integration/test_sensor_filters_delta.py": 12.63, - "tests/integration/test_sensor_filters_ring_buffer.py": 11.13, - "tests/integration/test_sensor_filters_sliding_window.py": 40.2, - "tests/integration/test_sensor_filters_value_list.py": 10.25, - "tests/integration/test_sensor_raw_state.py": 26.04, - "tests/integration/test_sensor_timeout_filter.py": 13.81, - "tests/integration/test_set_internal_at_boot.py": 14.13, - "tests/integration/test_snapshot_display.py": 16.36, - "tests/integration/test_socket_ipv4_resolve.py": 11.75, - "tests/integration/test_socket_tcp_client_link.py": 9.79, - "tests/integration/test_socket_wake_gate_tcp.py": 15.29, - "tests/integration/test_status_flags.py": 20.87, - "tests/integration/test_strftime_to.py": 10.06, - "tests/integration/test_syslog.py": 18.51, - "tests/integration/test_tcp_uart_bridge.py": 14.71, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 8.4, - "tests/integration/test_template_climate_basic.py": 14.22, - "tests/integration/test_template_climate_custom_modes.py": 11.61, - "tests/integration/test_template_climate_nonoptimistic.py": 13.47, - "tests/integration/test_template_climate_on_control_ordering.py": 13.99, - "tests/integration/test_template_climate_publish_all_fields.py": 10.98, - "tests/integration/test_template_climate_sensor_push.py": 14.86, - "tests/integration/test_template_climate_set_actions.py": 11.79, - "tests/integration/test_template_climate_two_point_temperature.py": 14.92, - "tests/integration/test_template_text_save.py": 13.35, - "tests/integration/test_template_time.py": 10.07, - "tests/integration/test_text_command.py": 12.11, - "tests/integration/test_text_sensor_raw_state.py": 13.96, - "tests/integration/test_uart_mock_ld2410.py": 30.03, - "tests/integration/test_uart_mock_ld2412.py": 35.57, - "tests/integration/test_uart_mock_ld2420.py": 26.6, - "tests/integration/test_uart_mock_ld2450.py": 13.03, - "tests/integration/test_uart_mock_modbus.py": 309.49, - "tests/integration/test_uart_tcp_bridge.py": 12.67, - "tests/integration/test_udp.py": 11.64, - "tests/integration/test_use_address_runtime.py": 16.79, - "tests/integration/test_valve_control_action.py": 12.76, - "tests/integration/test_varint_five_byte_device_id.py": 14.05, - "tests/integration/test_wait_until_mid_loop_timing.py": 12.48, - "tests/integration/test_wait_until_on_boot.py": 12.54, - "tests/integration/test_wait_until_ordering.py": 13.32, - "tests/integration/test_wait_until_reentrant_restart.py": 5.89, - "tests/integration/test_wake_loop_forces_phase_b.py": 13.8, - "tests/integration/test_water_heater_template.py": 24.77 + "tests/integration/test_action_concurrent_reentry.py": 14.33, + "tests/integration/test_addressable_light_transition.py": 18.53, + "tests/integration/test_alarm_control_panel_state_transitions.py": 15.5, + "tests/integration/test_api_action_metadata.py": 11.08, + "tests/integration/test_api_action_responses.py": 15.46, + "tests/integration/test_api_action_timeout.py": 16.54, + "tests/integration/test_api_conditional_memory.py": 11.84, + "tests/integration/test_api_custom_services.py": 13.37, + "tests/integration/test_api_decode_wire_types.py": 17.49, + "tests/integration/test_api_empty_message_roundtrip.py": 12.35, + "tests/integration/test_api_encode_boundaries.py": 10.93, + "tests/integration/test_api_get_time_response_timezone.py": 13.5, + "tests/integration/test_api_homeassistant.py": 18.7, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.96, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.76, + "tests/integration/test_api_homeassistant_entities.py": 15.12, + "tests/integration/test_api_list_entities_backpressure.py": 14.2, + "tests/integration/test_api_message_size_batching.py": 13.72, + "tests/integration/test_api_outgoing_connection.py": 28.66, + "tests/integration/test_api_reboot_timeout.py": 9.01, + "tests/integration/test_api_string_lambda.py": 12.16, + "tests/integration/test_api_vv_logging.py": 10.14, + "tests/integration/test_api_zero_psk_provisioning.py": 18.26, + "tests/integration/test_areas_and_devices.py": 10.34, + "tests/integration/test_automation_wait_actions.py": 9.93, + "tests/integration/test_automations.py": 20.02, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.34, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 14.03, + "tests/integration/test_binary_sensor_invalidate_state.py": 8.43, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 9.39, + "tests/integration/test_branch_resume_async.py": 16.64, + "tests/integration/test_build_info.py": 12.41, + "tests/integration/test_camera_mock.py": 13.29, + "tests/integration/test_climate_control_action.py": 15.18, + "tests/integration/test_climate_custom_modes.py": 14.12, + "tests/integration/test_continuation_actions.py": 14.06, + "tests/integration/test_cover_control_action.py": 9.59, + "tests/integration/test_crc8_helper.py": 11.51, + "tests/integration/test_device_id_in_state.py": 18.41, + "tests/integration/test_duplicate_entities.py": 14.95, + "tests/integration/test_entity_icon.py": 15.57, + "tests/integration/test_entity_missing_state.py": 13.91, + "tests/integration/test_fan_turn_on_action.py": 13.85, + "tests/integration/test_fnv1_hash_object_id.py": 10.12, + "tests/integration/test_fnv1a_hash.py": 9.95, + "tests/integration/test_gpio_expander_cache.py": 4.43, + "tests/integration/test_host_logger_thread_safety.py": 12.54, + "tests/integration/test_host_mode_basic.py": 12.66, + "tests/integration/test_host_mode_batch_delay.py": 13.34, + "tests/integration/test_host_mode_climate_basic_state.py": 15.19, + "tests/integration/test_host_mode_climate_control.py": 15.35, + "tests/integration/test_host_mode_empty_string_options.py": 13.04, + "tests/integration/test_host_mode_entity_fields.py": 10.42, + "tests/integration/test_host_mode_fan_preset.py": 11.7, + "tests/integration/test_host_mode_many_entities.py": 16.79, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 15.62, + "tests/integration/test_host_mode_noise_encryption.py": 21.06, + "tests/integration/test_host_mode_reconnect.py": 11.29, + "tests/integration/test_host_mode_sensor.py": 12.25, + "tests/integration/test_host_ota.py": 109.97, + "tests/integration/test_host_preferences.py": 12.92, + "tests/integration/test_host_preferences_suspend_resume.py": 12.3, + "tests/integration/test_host_write_after_peer_close.py": 14.42, + "tests/integration/test_improv_serial_uart.py": 16.29, + "tests/integration/test_ir_rf_transmit_complete.py": 10.73, + "tests/integration/test_large_message_batching.py": 13.94, + "tests/integration/test_legacy_area.py": 13.34, + "tests/integration/test_legacy_climate_compat.py": 13.19, + "tests/integration/test_legacy_fan_compat.py": 9.31, + "tests/integration/test_light_automations.py": 14.39, + "tests/integration/test_light_binary_effect_off_phase.py": 14.75, + "tests/integration/test_light_calls.py": 13.85, + "tests/integration/test_light_constant_brightness.py": 12.22, + "tests/integration/test_light_control_action.py": 15.46, + "tests/integration/test_light_dim_relative_action.py": 15.3, + "tests/integration/test_light_effect_zero_brightness.py": 16.75, + "tests/integration/test_light_initial_state.py": 9.8, + "tests/integration/test_light_repeat_effect.py": 22.27, + "tests/integration/test_light_restore_state.py": 15.67, + "tests/integration/test_light_resume_effect.py": 17.79, + "tests/integration/test_light_toggle_action.py": 15.22, + "tests/integration/test_light_transition_state_publish_interval.py": 44.96, + "tests/integration/test_lock_automations.py": 12.55, + "tests/integration/test_logger_buffered_recursion_guard.py": 11.78, + "tests/integration/test_loop_disable_enable.py": 13.19, + "tests/integration/test_loop_interval_decoupling.py": 15.14, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 14.91, + "tests/integration/test_lvgl_color_picker_render.py": 91.0, + "tests/integration/test_lvgl_headless_render.py": 46.26, + "tests/integration/test_micros_to_millis.py": 9.14, + "tests/integration/test_multi_click_trigger.py": 13.62, + "tests/integration/test_multi_device_preferences.py": 15.18, + "tests/integration/test_noise_encryption_key_protection.py": 21.14, + "tests/integration/test_object_id_api_verification.py": 9.54, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 8.78, + "tests/integration/test_object_id_no_friendly_name.py": 24.2, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 5.07, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 26.9, + "tests/integration/test_online_image_bmp.py": 24.53, + "tests/integration/test_oversized_payloads.py": 43.03, + "tests/integration/test_preference_key_stability.py": 14.22, + "tests/integration/test_runtime_stats.py": 15.85, + "tests/integration/test_safe_mode_loop_runs.py": 8.89, + "tests/integration/test_scheduler_background_wake.py": 17.82, + "tests/integration/test_scheduler_blocking_warning.py": 30.27, + "tests/integration/test_scheduler_bulk_cleanup.py": 8.78, + "tests/integration/test_scheduler_defer_cancel.py": 12.9, + "tests/integration/test_scheduler_defer_cancel_regular.py": 12.79, + "tests/integration/test_scheduler_defer_fifo_simple.py": 13.14, + "tests/integration/test_scheduler_defer_stress.py": 13.2, + "tests/integration/test_scheduler_heap_stress.py": 9.15, + "tests/integration/test_scheduler_internal_id_no_collision.py": 14.81, + "tests/integration/test_scheduler_interval_reschedule.py": 13.07, + "tests/integration/test_scheduler_interval_zero_coerced.py": 12.32, + "tests/integration/test_scheduler_null_name.py": 3.81, + "tests/integration/test_scheduler_numeric_id_test.py": 9.77, + "tests/integration/test_scheduler_pool.py": 10.16, + "tests/integration/test_scheduler_rapid_cancellation.py": 4.7, + "tests/integration/test_scheduler_recursive_timeout.py": 13.72, + "tests/integration/test_scheduler_removed_item_race.py": 12.73, + "tests/integration/test_scheduler_self_keyed.py": 13.03, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 11.88, + "tests/integration/test_scheduler_string_test.py": 14.1, + "tests/integration/test_script_array_params.py": 11.14, + "tests/integration/test_script_delay_params.py": 13.84, + "tests/integration/test_script_queued.py": 15.45, + "tests/integration/test_script_queued_idle_loop.py": 13.15, + "tests/integration/test_script_wait_on_boot.py": 12.64, + "tests/integration/test_sdl_headless_screenshot.py": 15.19, + "tests/integration/test_select_stringref_trigger.py": 13.51, + "tests/integration/test_sensor_filters_delta.py": 13.6, + "tests/integration/test_sensor_filters_ring_buffer.py": 9.22, + "tests/integration/test_sensor_filters_sliding_window.py": 40.87, + "tests/integration/test_sensor_filters_value_list.py": 15.43, + "tests/integration/test_sensor_raw_state.py": 25.12, + "tests/integration/test_sensor_timeout_filter.py": 9.64, + "tests/integration/test_sensor_timeout_throttle_filter.py": 14.63, + "tests/integration/test_set_internal_at_boot.py": 13.18, + "tests/integration/test_snapshot_display.py": 16.01, + "tests/integration/test_socket_ipv4_resolve.py": 11.3, + "tests/integration/test_socket_tcp_client_link.py": 13.88, + "tests/integration/test_socket_wake_gate_tcp.py": 14.28, + "tests/integration/test_status_flags.py": 20.55, + "tests/integration/test_status_momentary.py": 12.63, + "tests/integration/test_strftime_to.py": 9.47, + "tests/integration/test_syslog.py": 17.73, + "tests/integration/test_tcp_uart_bridge.py": 16.27, + "tests/integration/test_tcp_uart_server.py": 13.64, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.42, + "tests/integration/test_template_climate_basic.py": 14.42, + "tests/integration/test_template_climate_custom_modes.py": 14.3, + "tests/integration/test_template_climate_nonoptimistic.py": 10.11, + "tests/integration/test_template_climate_on_control_ordering.py": 4.57, + "tests/integration/test_template_climate_publish_all_fields.py": 12.56, + "tests/integration/test_template_climate_sensor_push.py": 14.89, + "tests/integration/test_template_climate_set_actions.py": 14.12, + "tests/integration/test_template_climate_two_point_temperature.py": 9.08, + "tests/integration/test_template_text_save.py": 13.96, + "tests/integration/test_template_time.py": 12.73, + "tests/integration/test_text_command.py": 13.09, + "tests/integration/test_text_sensor_raw_state.py": 14.3, + "tests/integration/test_uart_mock_ld2410.py": 33.11, + "tests/integration/test_uart_mock_ld2412.py": 61.26, + "tests/integration/test_uart_mock_ld2420.py": 23.39, + "tests/integration/test_uart_mock_ld2450.py": 11.79, + "tests/integration/test_uart_mock_modbus.py": 323.58, + "tests/integration/test_uart_mock_ufm01.py": 71.03, + "tests/integration/test_uart_tcp_bridge.py": 13.3, + "tests/integration/test_udp.py": 7.04, + "tests/integration/test_use_address_runtime.py": 12.4, + "tests/integration/test_valve_control_action.py": 9.25, + "tests/integration/test_varint_five_byte_device_id.py": 9.34, + "tests/integration/test_wait_until_mid_loop_timing.py": 13.21, + "tests/integration/test_wait_until_on_boot.py": 12.66, + "tests/integration/test_wait_until_ordering.py": 9.06, + "tests/integration/test_wait_until_reentrant_restart.py": 10.83, + "tests/integration/test_wake_loop_forces_phase_b.py": 14.23, + "tests/integration/test_water_heater_template.py": 21.96 } From 36082b23c2b4c4000f82a29e76ca086fc374eacb Mon Sep 17 00:00:00 2001 From: RobertH1993 Date: Mon, 12 Oct 2026 08:11:15 +0200 Subject: [PATCH 161/268] [remote_base] Add the Somfy RTS transmit protocol (#16502) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 40 +++++++++++ .../remote_base/somfy_rts_protocol.cpp | 69 +++++++++++++++++++ .../remote_base/somfy_rts_protocol.h | 21 ++++++ esphome/core/defines.h | 1 + .../remote_transmitter/common-buttons.yaml | 21 ++++++ 5 files changed, 152 insertions(+) create mode 100644 esphome/components/remote_base/somfy_rts_protocol.cpp create mode 100644 esphome/components/remote_base/somfy_rts_protocol.h diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index af043c0ea8..fea3726f76 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -27,6 +27,7 @@ from esphome.const import ( CONF_ID, CONF_INDEX, CONF_INVERTED, + CONF_KEY, CONF_LEVEL, CONF_MAGNITUDE, CONF_NBITS, @@ -63,6 +64,9 @@ CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" CONF_SUFFIX = "suffix" CONF_SUFFIX_BITS = "suffix_bits" +CONF_CONTROL = "control" +CONF_CONTROL_IN_KEY = "control_in_key" +CONF_ROLLING_CODE = "rolling_code" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -2114,6 +2118,42 @@ register_protocol_action( ) +# SomfyRts +SOMFY_RTS_SCHEMA = cv.Schema( + { + cv.Required(CONF_KEY): cv.hex_uint8_t, + cv.Required(CONF_CONTROL): cv.All(cv.hex_uint8_t, cv.Range(max=0x0F)), + cv.Required(CONF_ROLLING_CODE): cv.hex_uint16_t, + cv.Required(CONF_ADDRESS): cv.All(cv.hex_uint32_t, cv.Range(max=0xFFFFFF)), + cv.Optional(CONF_CONTROL_IN_KEY, default=False): cv.boolean, + } +) + + +def _validate_somfy_rts(config: ConfigType) -> ConfigType: + key = config[CONF_KEY] + if config[CONF_CONTROL_IN_KEY] is True and isinstance(key, int) and key & 0x0F: + raise cv.Invalid( + f"With {CONF_CONTROL_IN_KEY} the low nibble of {CONF_KEY} carries {CONF_CONTROL} " + "and must be 0", + path=[CONF_KEY], + ) + return config + + +# Transmit only: the protocol has no decoder yet, so no binary sensor, trigger or dumper. +register_protocol_action( + "somfy_rts", + "SomfyRts", + SOMFY_RTS_SCHEMA, + (CONF_KEY, "key", cg.uint8), + (CONF_CONTROL, "control", cg.uint8), + (CONF_ROLLING_CODE, "rolling_code", cg.uint16), + (CONF_ADDRESS, "address", cg.uint32), + (CONF_CONTROL_IN_KEY, "control_in_key", cg.bool_), + validate=_validate_somfy_rts, +) + # Nexa NexaData, NexaBinarySensor, NexaTrigger, _, NexaDumper = declare_protocol("Nexa") NEXA_SCHEMA = cv.Schema( diff --git a/esphome/components/remote_base/somfy_rts_protocol.cpp b/esphome/components/remote_base/somfy_rts_protocol.cpp new file mode 100644 index 0000000000..21c1f1d457 --- /dev/null +++ b/esphome/components/remote_base/somfy_rts_protocol.cpp @@ -0,0 +1,69 @@ +#include "somfy_rts_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +// Timings from https://pushstack.wordpress.com/somfy-rts-protocol/ +static constexpr uint8_t FRAME_SIZE_IN_BYTES = 7; +static constexpr uint16_t HALF_BIT_US = 604; + +static constexpr uint16_t WAKEUP_HIGH_US = 9415; +static constexpr uint32_t WAKEUP_LOW_US = 89565; + +static constexpr uint16_t HW_SYNC_US = 2560; + +static constexpr uint16_t SW_SYNC_HIGH_US = 4550; +static constexpr uint16_t SW_SYNC_LOW_US = HALF_BIT_US; + +// Wakeup, two hardware syncs and a software sync (8), two entries per bit (112) +static constexpr uint32_t FRAME_ENTRIES = 8 + FRAME_SIZE_IN_BYTES * 8 * 2; + +void SomfyRtsProtocol::encode(RemoteTransmitData *dst, const SomfyRtsData &data) { + dst->reserve(FRAME_ENTRIES); + dst->set_carrier_frequency(0); + dst->item(WAKEUP_HIGH_US, WAKEUP_LOW_US); + dst->item(HW_SYNC_US, HW_SYNC_US); + dst->item(HW_SYNC_US, HW_SYNC_US); + dst->item(SW_SYNC_HIGH_US, SW_SYNC_LOW_US); + + uint8_t frame[FRAME_SIZE_IN_BYTES]; + // Some non standard remotes put the control nibble in the key byte and send 0xF in the control nibble + if (data.control_in_key) { + frame[0] = (data.key & 0xF0) | (data.control & 0x0F); + frame[1] = 0xF0; + } else { + frame[0] = data.key; + frame[1] = (data.control & 0x0F) << 4; + } + frame[2] = data.rolling_code >> 8; + frame[3] = data.rolling_code; + frame[4] = data.address >> 16; + frame[5] = data.address >> 8; + frame[6] = data.address; + + uint8_t crc = 0; + for (uint8_t data_byte : frame) { + crc ^= (data_byte >> 4) ^ (data_byte & 0xF); + } + frame[1] |= (crc & 0x0F); + + for (uint8_t i = 1; i < FRAME_SIZE_IN_BYTES; i++) { + frame[i] ^= frame[i - 1]; + } + + // Manchester, MSB first: a 1 is space then mark, a 0 is mark then space. Frames repeat with + // the 30 ms gap through the action's repeat option, not inside the frame. + for (uint8_t data_byte : frame) { + for (int8_t y = 7; y >= 0; y--) { + if (data_byte & (1 << y)) { + dst->space(HALF_BIT_US); + dst->mark(HALF_BIT_US); + } else { + dst->mark(HALF_BIT_US); + dst->space(HALF_BIT_US); + } + } + } +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/somfy_rts_protocol.h b/esphome/components/remote_base/somfy_rts_protocol.h new file mode 100644 index 0000000000..df6ddd4831 --- /dev/null +++ b/esphome/components/remote_base/somfy_rts_protocol.h @@ -0,0 +1,21 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct SomfyRtsData { + uint32_t address; + uint16_t rolling_code; + uint8_t key; + uint8_t control; + bool control_in_key; +}; + +// Transmit only: there is no decoder yet, so no binary sensor, trigger or dumper is registered. +class SomfyRtsProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const SomfyRtsData &data); +}; + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7ff80cbbd1..27cfa08c43 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -187,6 +187,7 @@ #define USE_REMOTE_PROTOCOL_ROOMBA #define USE_REMOTE_PROTOCOL_SAMSUNG #define USE_REMOTE_PROTOCOL_SAMSUNG36 +#define USE_REMOTE_PROTOCOL_SOMFY_RTS #define USE_REMOTE_PROTOCOL_SONY #define USE_REMOTE_PROTOCOL_SYMPHONY #define USE_REMOTE_PROTOCOL_TOSHIBA_AC diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index c81a8687d6..97ce6a37ed 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -419,3 +419,24 @@ button: on_press: remote_transmitter.transmit_roomba: data: 0x88 + - platform: template + name: SomfyRTS + on_press: + - remote_transmitter.transmit_somfy_rts: + address: 0x630139 + control: 0x08 + key: 0xA0 + rolling_code: 1234 + control_in_key: false + - platform: template + name: SomfyRTS Control In Key + on_press: + - remote_transmitter.transmit_somfy_rts: + address: 0x630139 + control: 0x02 + key: 0xA0 + rolling_code: !lambda return 1235; + control_in_key: true + repeat: + times: 3 + wait_time: 30ms From eb8a793f6ef3f8a6aa9ac33956bca5eaf684cda6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 20:22:11 -1000 Subject: [PATCH 162/268] [core] Add a name truncation helper and keep the MAC suffix in the nRF52 BLE name (#19768) --- esphome/components/esp32_ble/ble.cpp | 15 +------ esphome/components/wifi/wifi_component.cpp | 23 +++-------- .../zephyr_ble_server/ble_server.cpp | 25 +++++++---- esphome/core/helpers.cpp | 20 +++++++++ esphome/core/helpers.h | 10 +++++ tests/components/core/test_helpers.cpp | 41 +++++++++++++++++++ 6 files changed, 95 insertions(+), 39 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index b5051c63e1..11290a95f4 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -350,19 +350,8 @@ bool ESP32BLE::ble_setup_() { } } else { const auto &app_name = App.get_name(); - size_t name_len = app_name.length(); - if (name_len > 20) { - if (App.is_name_add_mac_suffix_enabled()) { - // Keep first 13 chars and last 7 chars (MAC suffix), remove middle - memcpy(name_buffer, app_name.c_str(), 13); - memcpy(name_buffer + 13, app_name.c_str() + name_len - 7, 7); - } else { - memcpy(name_buffer, app_name.c_str(), 20); - } - name_buffer[20] = '\0'; - } else { - memcpy(name_buffer, app_name.c_str(), name_len + 1); // Include null terminator - } + truncate_name_to(name_buffer, sizeof(name_buffer), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); device_name = name_buffer; } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 9a9ca2cbb2..f973d146e5 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1048,30 +1048,17 @@ void WiFiComponent::setup_ap_config_() { return; if (this->ap_.ssid_.empty()) { - // Build AP SSID from app name without heap allocation - // WiFi SSID max is 32 bytes, with MAC suffix we keep first 25 + last 7 + // Build AP SSID from app name without heap allocation; WiFi SSID max is 32 bytes static constexpr size_t AP_SSID_MAX_LEN = 32; - static constexpr size_t AP_SSID_PREFIX_LEN = 25; - static constexpr size_t AP_SSID_SUFFIX_LEN = 7; const auto &app_name = App.get_name(); - const char *name_ptr = app_name.c_str(); - size_t name_len = app_name.length(); - - if (name_len <= AP_SSID_MAX_LEN) { + if (app_name.length() <= AP_SSID_MAX_LEN) { // Name fits, use directly - this->ap_.set_ssid(name_ptr); + this->ap_.set_ssid(app_name.c_str()); } else { - // Name too long, need to truncate into stack buffer char ssid_buf[AP_SSID_MAX_LEN + 1]; - if (App.is_name_add_mac_suffix_enabled()) { - // Keep first 25 chars and last 7 chars (MAC suffix), remove middle - memcpy(ssid_buf, name_ptr, AP_SSID_PREFIX_LEN); - memcpy(ssid_buf + AP_SSID_PREFIX_LEN, name_ptr + name_len - AP_SSID_SUFFIX_LEN, AP_SSID_SUFFIX_LEN); - } else { - memcpy(ssid_buf, name_ptr, AP_SSID_MAX_LEN); - } - ssid_buf[AP_SSID_MAX_LEN] = '\0'; + truncate_name_to(ssid_buf, sizeof(ssid_buf), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); this->ap_.set_ssid(ssid_buf); } } diff --git a/esphome/components/zephyr_ble_server/ble_server.cpp b/esphome/components/zephyr_ble_server/ble_server.cpp index dd6ecd3b20..c890fba3d7 100644 --- a/esphome/components/zephyr_ble_server/ble_server.cpp +++ b/esphome/components/zephyr_ble_server/ble_server.cpp @@ -1,6 +1,8 @@ #ifdef USE_ZEPHYR #include "ble_server.h" +#include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include @@ -13,13 +15,8 @@ static k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-globa BLEServer *global_ble_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#define DEVICE_NAME CONFIG_BT_DEVICE_NAME -#define DEVICE_NAME_LEN (sizeof(DEVICE_NAME) - 1) - -static const bt_data AD[] = { - BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), - BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, DEVICE_NAME_LEN), -}; +// What the advertisement has left for the name: the flags take 3 bytes and the name header 2 +static constexpr size_t MAX_ADV_NAME_LEN = BT_GAP_ADV_MAX_ADV_DATA_LEN - 3 - 2; static const bt_data SD[] = { #ifdef USE_OTA @@ -36,7 +33,19 @@ static void advertise(k_work *work) { ESP_LOGE(TAG, "Advertising failed to stop (rc %d)", rc); } - rc = bt_le_adv_start(ADV_PARAM, AD, ARRAY_SIZE(AD), SD, ARRAY_SIZE(SD)); + // Zephyr would cut the end of a long name, which is where the MAC suffix is + char name[MAX_ADV_NAME_LEN + 1]; + const auto &app_name = App.get_name(); + const size_t name_len = + truncate_name_to(name, sizeof(name), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); + const uint8_t name_type = name_len < app_name.length() ? BT_DATA_NAME_SHORTENED : BT_DATA_NAME_COMPLETE; + const bt_data ad[] = { + BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), + BT_DATA(name_type, name, static_cast(name_len)), + }; + + rc = bt_le_adv_start(ADV_PARAM, ad, ARRAY_SIZE(ad), SD, ARRAY_SIZE(SD)); if (rc) { ESP_LOGE(TAG, "Advertising failed to start (rc %d)", rc); return; diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1b332515f6..906f9fb12a 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -293,6 +293,26 @@ size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *na return total_len; } +size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, size_t keep_suffix_len) { + if (buffer_size == 0) { + return 0; + } + const size_t max_len = buffer_size - 1; + if (name_len <= max_len) { + memcpy(buffer, name, name_len); + buffer[name_len] = '\0'; + return name_len; + } + if (keep_suffix_len > max_len) { + keep_suffix_len = max_len; + } + const size_t head_len = max_len - keep_suffix_len; + memcpy(buffer, name, head_len); + memcpy(buffer + head_len, name + name_len - keep_suffix_len, keep_suffix_len); + buffer[max_len] = '\0'; + return max_len; +} + // Parsing & formatting size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 50be278bb3..bcb03b010f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1256,6 +1256,16 @@ static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep, const char *suffix_ptr, size_t suffix_len); +/// Length of the suffix name_add_mac_suffix appends: "-" plus 6 characters of the MAC address +static constexpr size_t MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN = 7; + +/// Copy a name into a buffer, cutting it when it does not fit. +/// A name that is cut keeps its last keep_suffix_len characters (for example a MAC suffix) and loses +/// the middle instead; pass 0 to cut the end. +/// @param buffer Output buffer; holds at most buffer_size - 1 characters plus the null terminator +/// @return Length written (excluding null terminator) +size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, size_t keep_suffix_len); + ///@} /// @name Parsing & formatting diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 9fee0c723b..ac71cf8232 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -7,6 +7,47 @@ namespace esphome::core::testing { +// --- truncate_name_to() --- + +TEST(TruncateNameTo, NameThatFitsIsCopied) { + char buffer[21]; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), "kitchen", 7, 7), 7u); + EXPECT_STREQ(buffer, "kitchen"); +} + +TEST(TruncateNameTo, NameOfExactlyTheLimitIsCopied) { + char buffer[8]; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), "kitchen", 7, 0), 7u); + EXPECT_STREQ(buffer, "kitchen"); +} + +TEST(TruncateNameTo, CutsTheEndWithoutASuffix) { + char buffer[21]; + const char *name = "a-very-long-device-name-here"; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), name, strlen(name), 0), 20u); + EXPECT_STREQ(buffer, "a-very-long-device-n"); +} + +TEST(TruncateNameTo, KeepsTheSuffixAndRemovesTheMiddle) { + char buffer[21]; + const char *name = "a-very-long-device-name-a1b2c3"; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), name, strlen(name), 7), 20u); + EXPECT_STREQ(buffer, "a-very-long-d-a1b2c3"); +} + +TEST(TruncateNameTo, SuffixLongerThanTheBufferIsCut) { + char buffer[5]; + const char *name = "name-a1b2c3"; + EXPECT_EQ(truncate_name_to(buffer, sizeof(buffer), name, strlen(name), 7), 4u); + EXPECT_STREQ(buffer, "b2c3"); +} + +TEST(TruncateNameTo, ZeroBufferSize) { + char buffer[1] = {'x'}; + EXPECT_EQ(truncate_name_to(buffer, 0, "name", 4, 0), 0u); + EXPECT_EQ(buffer[0], 'x'); +} + // --- format_hex_to() --- TEST(FormatHexTo, Basic) { From 0c645ac3a19dcde33c8667c584a65b124f8f219a Mon Sep 17 00:00:00 2001 From: Gildas Le Nadan <3ntr0p13+github@gmail.com> Date: Mon, 12 Oct 2026 08:24:50 +0200 Subject: [PATCH 163/268] [remote_base] Adding Marantz RC5 Extended protocol (#13164) Co-authored-by: Claude Sonnet 4.5 Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 42 +++++ .../components/remote_base/rc5x_protocol.cpp | 124 +++++++++++++ .../components/remote_base/rc5x_protocol.h | 31 ++++ esphome/core/defines.h | 1 + tests/components/remote_base/__init__.py | 10 +- tests/components/remote_base/rc5x_test.cpp | 164 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 + .../remote_receiver/esp32-common.yaml | 6 + .../remote_transmitter/common-buttons.yaml | 7 + 9 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_base/rc5x_protocol.cpp create mode 100644 esphome/components/remote_base/rc5x_protocol.h create mode 100644 tests/components/remote_base/rc5x_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index fea3726f76..cbe6aa8b49 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -67,6 +67,7 @@ CONF_SUFFIX_BITS = "suffix_bits" CONF_CONTROL = "control" CONF_CONTROL_IN_KEY = "control_in_key" CONF_ROLLING_CODE = "rolling_code" +CONF_EXTENSION = "extension" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -1575,6 +1576,47 @@ register_protocol_action( ) +# RC5X (aka RC5 Marantz) +RC5XData, RC5XBinarySensor, RC5XTrigger, _, RC5XDumper = declare_protocol("RC5X") +RC5X_SCHEMA = RC5_SCHEMA.extend( + {cv.Required(CONF_EXTENSION): cv.hex_int_range(min=0, max=0x3F)} +) + + +@register_binary_sensor("rc5x", RC5XBinarySensor, RC5X_SCHEMA) +def rc5x_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + RC5XData, + ("address", config[CONF_ADDRESS]), + ("command", config[CONF_COMMAND]), + ("extension", config[CONF_EXTENSION]), + ) + ) + ) + + +@register_trigger("rc5x", RC5XTrigger, RC5XData) +def rc5x_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("rc5x", RC5XDumper) +def rc5x_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "rc5x", + "RC5X", + RC5X_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + (CONF_EXTENSION, "extension", cg.uint8), +) + + # RC6 RC6Data, RC6BinarySensor, RC6Trigger, RC6Action, RC6Dumper = declare_protocol("RC6") RC6_SCHEMA = cv.Schema( diff --git a/esphome/components/remote_base/rc5x_protocol.cpp b/esphome/components/remote_base/rc5x_protocol.cpp new file mode 100644 index 0000000000..501766f0aa --- /dev/null +++ b/esphome/components/remote_base/rc5x_protocol.cpp @@ -0,0 +1,124 @@ +#include "rc5x_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.rc5x"); + +static constexpr uint32_t HALF_BIT_US = 889; // a Manchester bit is two half-bits +// Start, field and toggle bits plus the address, then a pause of 4 half-bits, then command and extension +static constexpr uint8_t RC5_TOGGLE_BIT = 1; +static constexpr uint8_t RC5_ADDRESS_BITS = 5; +static constexpr uint8_t FIRST_PART_NBITS = 8; +static constexpr uint8_t RC5_COMMAND_BITS = 6; +static constexpr uint8_t RC5_EXT_BITS = 6; +static constexpr uint8_t NBITS = 20; +static constexpr uint8_t PAUSE_HALFBITS = 4; +// Half-bits in a frame, counting the pause; the pause starts after the first part's half-bits +static constexpr uint8_t NFRAMEHALFBITS = NBITS * 2 + PAUSE_HALFBITS; +static constexpr uint8_t PAUSE_START = FIRST_PART_NBITS * 2; + +void RC5XProtocol::encode(RemoteTransmitData *dst, const RC5XData &data) { + static bool toggle = false; + dst->set_carrier_frequency(36000); + dst->reserve(NFRAMEHALFBITS); + // Start bit, then the field bit, which is clear for commands 64 to 127 + const uint8_t command = data.command & 0x7F; // bit 6 travels in the field bit, higher bits have no place + const uint32_t start_field = command >= 64 ? 0b10 : 0b11; + const uint32_t out_data = start_field << (RC5_TOGGLE_BIT + RC5_ADDRESS_BITS + RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(toggle) << (RC5_ADDRESS_BITS + RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(data.address & 0x1F) << (RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(command & 0x3F) << RC5_EXT_BITS | (data.extension & 0x3F); + + for (uint8_t bit = NBITS; bit-- > 0;) { + if (bit == NBITS - 1 - FIRST_PART_NBITS) { + for (uint8_t i = 0; i < PAUSE_HALFBITS; i++) + dst->space(HALF_BIT_US); + } + if (out_data & (1UL << bit)) { + dst->space(HALF_BIT_US); + dst->mark(HALF_BIT_US); + } else { + dst->mark(HALF_BIT_US); + dst->space(HALF_BIT_US); + } + } + toggle = !toggle; +} + +optional RC5XProtocol::decode(RemoteReceiveData src) { + // Expand the runs into half-bit levels (true = mark), as the RC5 decoder does. Each run is one + // half-bit (HALF_BIT_US) or two, except the pause, which is PAUSE_HALFBITS half-bits at the idle + // level plus up to one merged half on each side (the end of bit 12 and the start of bit 11). + // + // halfbits[0] is reserved for the leading half-bit, which is always dropped: S1 is 1, so its + // first half sits at the idle level and merges into the pre-frame idle. + bool halfbits[NFRAMEHALFBITS + 2]; + uint8_t n = 1; + bool pause_seen = false; + for (uint32_t i = 0; n <= NFRAMEHALFBITS && src.is_valid(i); i++) { + if (src.peek_mark(HALF_BIT_US, i)) { + halfbits[n++] = true; + } else if (src.peek_space(HALF_BIT_US, i)) { + halfbits[n++] = false; + } else if (src.peek_mark(2 * HALF_BIT_US, i)) { + halfbits[n++] = true; + halfbits[n++] = true; + } else if (src.peek_space(2 * HALF_BIT_US, i)) { + halfbits[n++] = false; + halfbits[n++] = false; + } else { + // The pause must start right after bit 12, or one half earlier when bit 12 ends idle + if (pause_seen || (n != PAUSE_START && n != PAUSE_START - 1)) + break; + const int32_t value = src.peek(i); + const bool level = value > 0; + const uint32_t halves = (static_cast(value > 0 ? value : -value) + HALF_BIT_US / 2) / HALF_BIT_US; + if (level == halfbits[1] || halves < PAUSE_HALFBITS || halves > PAUSE_HALFBITS + 2) + break; + for (uint32_t h = 0; h < halves; h++) + halfbits[n++] = level; + // It may also run one half into bit 11, but must cover the whole pause + if (n != PAUSE_START + PAUSE_HALFBITS && n != PAUSE_START + PAUSE_HALFBITS + 1) + return {}; + pause_seen = true; + } + } + + // A full frame, or one half short when the last bit ends idle and its trailing half is dropped. + // A dropped edge half is the inverse of its partner, since a Manchester bit always changes mid-bit. + if (!pause_seen || (n != NFRAMEHALFBITS && n != NFRAMEHALFBITS - 1)) { + return {}; + } + halfbits[0] = !halfbits[1]; + if (n == NFRAMEHALFBITS - 1) { + halfbits[n] = !halfbits[n - 1]; + } + + const bool carrier = halfbits[1]; + uint32_t bits = 0; + for (uint8_t i = 0; i < NBITS; i++) { + const uint8_t h = 2 * i + (i < FIRST_PART_NBITS ? 0 : PAUSE_HALFBITS); + const bool first = halfbits[h]; + const bool second = halfbits[h + 1]; + if (first == second) { + return {}; // no midpoint transition -> not a valid Manchester bit + } + bits = (bits << 1) | (second == carrier ? 1 : 0); + } + + // [18 field] [17 toggle] [16-12 address] [11-6 command] [5-0 extension] + const bool field_bit = bits & (1UL << (NBITS - 2)); // S2: the inverted 7th command bit + return RC5XData{ + .address = static_cast((bits >> (RC5_COMMAND_BITS + RC5_EXT_BITS)) & 0x1F), + .command = static_cast(((bits >> RC5_EXT_BITS) & 0x3F) | (field_bit ? 0 : 0x40)), + .extension = static_cast(bits & 0x3F), + }; +} + +void RC5XProtocol::dump(const RC5XData &data) { + ESP_LOGI(TAG, "Received RC5X (Marantz): address=0x%02X, command=0x%02X, extension=0x%02X", data.address, data.command, + data.extension); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5x_protocol.h b/esphome/components/remote_base/rc5x_protocol.h new file mode 100644 index 0000000000..1d1b2c003d --- /dev/null +++ b/esphome/components/remote_base/rc5x_protocol.h @@ -0,0 +1,31 @@ +#pragma once + +#include "remote_base.h" + +/* + * Marantz RC5 extended protocol (RC5x) + * See https://arduino-irremote.github.io/Arduino-IRremote/ir__RC5__RC6_8hpp_source.html + */ + +namespace esphome::remote_base { + +struct RC5XData { + uint8_t address; + uint8_t command; + uint8_t extension; + + bool operator==(const RC5XData &rhs) const { + return address == rhs.address && command == rhs.command && extension == rhs.extension; + } +}; + +class RC5XProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const RC5XData &data); + optional decode(RemoteReceiveData src); + void dump(const RC5XData &data); +}; + +DECLARE_REMOTE_PROTOCOL(RC5X) + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 27cfa08c43..0a876b4f0c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -182,6 +182,7 @@ #define USE_REMOTE_PROTOCOL_PRONTO #define USE_REMOTE_PROTOCOL_RAW #define USE_REMOTE_PROTOCOL_RC5 +#define USE_REMOTE_PROTOCOL_RC5X #define USE_REMOTE_PROTOCOL_RC6 #define USE_REMOTE_PROTOCOL_RC_SWITCH #define USE_REMOTE_PROTOCOL_ROOMBA diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 572505b01a..927ff02252 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,15 @@ 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", "keeloq", "nexus"): + for protocol in ( + "hob2hood", + "haier", + "mirage", + "aeha", + "keeloq", + "nexus", + "rc5x", + ): request_protocol(protocol) manifest.to_code = to_code_testing diff --git a/tests/components/remote_base/rc5x_test.cpp b/tests/components/remote_base/rc5x_test.cpp new file mode 100644 index 0000000000..44ab365f97 --- /dev/null +++ b/tests/components/remote_base/rc5x_test.cpp @@ -0,0 +1,164 @@ +#include +#include "esphome/components/remote_base/rc5x_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +RawTimings encode(const RC5XData &data) { + RemoteTransmitData tx; + RC5XProtocol().encode(&tx, data); + return tx.get_data(); +} + +// A receiver starts on the first mark, sees runs of one level as one item, and may drop a trailing space. +RawTimings as_received(const RawTimings &timings, bool drop_tail = false) { + RawTimings merged; + for (int32_t item : timings) { + if (merged.empty() && item < 0) { + continue; + } + 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; +} + +RawTimings inverted(RawTimings timings) { + for (int32_t &item : timings) { + item = -item; + } + return timings; +} + +optional decode(const RawTimings &timings) { + return RC5XProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_round_trip(const RC5XData &sent) { + // encode() flips the toggle on every call, so two calls cover both toggle values + const RawTimings first = encode(sent); + const RawTimings second = encode(sent); + ASSERT_NE(first, second); + for (const RawTimings &timings : {first, second}) { + for (bool drop_tail : {false, true}) { + auto decoded = decode(as_received(timings, drop_tail)); + ASSERT_TRUE(decoded.has_value()) << std::hex << "address 0x" << int(sent.address) << " command 0x" + << int(sent.command) << " extension 0x" << int(sent.extension); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, sent); + } + } +} + +} // namespace + +TEST(RC5XProtocolTest, RoundTripsBothToggleValues) { + expect_round_trip({.address = 0x10, .command = 0x00, .extension = 0x06}); + expect_round_trip({.address = 0x1F, .command = 0x0D, .extension = 0x15}); +} + +TEST(RC5XProtocolTest, RoundTripsCommandsWithBit5Set) { + expect_round_trip({.address = 0x10, .command = 0x20, .extension = 0x00}); + expect_round_trip({.address = 0x01, .command = 0x3F, .extension = 0x2A}); + expect_round_trip({.address = 0x00, .command = 0x25, .extension = 0x3F}); +} + +TEST(RC5XProtocolTest, RoundTripsExtendedCommands) { + expect_round_trip({.address = 0x10, .command = 0x40, .extension = 0x06}); + expect_round_trip({.address = 0x0A, .command = 0x55, .extension = 0x00}); + expect_round_trip({.address = 0x15, .command = 0x7F, .extension = 0x3F}); + expect_round_trip({.address = 0x10, .command = 0x60, .extension = 0x01}); +} + +TEST(RC5XProtocolTest, RoundTripsExtensionLimits) { + for (uint8_t address : {0x00, 0x1F}) { + for (uint8_t command : {0x00, 0x1F, 0x20, 0x3F, 0x40, 0x7F}) { + expect_round_trip({.address = address, .command = command, .extension = 0x00}); + expect_round_trip({.address = address, .command = command, .extension = 0x3F}); + } + } +} + +TEST(RC5XProtocolTest, DecodesCapturedMarantzAux1) { + // Captured AUX1 code (address 0x10, command 0x00, extension 0x06, toggle set) from the PR description + const RawTimings captured = {889, -889, 889, -889, 889, -889, 1778, -889, 889, -889, 889, -889, + 889, -4450, 889, -889, 889, -889, 889, -889, 889, -889, 889, -889, + 889, -889, 889, -889, 889, -889, 889, -1778, 889, -889, 1778}; + auto decoded = decode(captured); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, 0x10); + EXPECT_EQ(decoded->command, 0x00); + EXPECT_EQ(decoded->extension, 0x06); +} + +TEST(RC5XProtocolTest, EncodeMatchesCapturedMarantzAux1) { + const RawTimings captured = {889, -889, 889, -889, 889, -889, 1778, -889, 889, -889, 889, -889, + 889, -4450, 889, -889, 889, -889, 889, -889, 889, -889, 889, -889, + 889, -889, 889, -889, 889, -889, 889, -1778, 889, -889, 1778}; + const RC5XData aux1{.address = 0x10, .command = 0x00, .extension = 0x06}; + // The capture has the toggle set; one of two consecutive frames does too + const RawTimings first = as_received(encode(aux1), true); + const RawTimings second = as_received(encode(aux1), true); + const RawTimings &toggled = first[6] == 1778 ? first : second; + ASSERT_EQ(toggled.size(), captured.size()); + for (size_t i = 0; i < captured.size(); i++) { + // The pause is 4 x 889 plus the half-bit before it, sent as 4445 against 4450 captured + EXPECT_NEAR(toggled[i], captured[i], 5) << i; + } +} + +TEST(RC5XProtocolTest, DecodesAtInvertedPolarity) { + const RC5XData sent{.address = 0x10, .command = 0x6A, .extension = 0x2B}; + // With the pin inverted, every carrier half reads as a space + const RawTimings timings = inverted(as_received(encode(sent), true)); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, sent); +} + +TEST(RC5XProtocolTest, MasksOutOfRangeFields) { + // Bit 7 of the command has no place on the wire and is dropped like the other high bits + RC5XData sent{.address = 0xF0, .command = 0xC5, .extension = 0xC6}; + auto decoded = decode(as_received(encode(sent))); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, 0x10); + EXPECT_EQ(decoded->command, 0x45); + EXPECT_EQ(decoded->extension, 0x06); +} + +TEST(RC5XProtocolTest, RejectsPlainRc5AndBrokenFrames) { + // Without the pause the frame is too short + RawTimings timings = as_received(encode({.address = 0x10, .command = 0x00, .extension = 0x06})); + RawTimings no_pause; + for (int32_t item : timings) { + no_pause.push_back(item < -3000 ? -889 : item); + } + EXPECT_FALSE(decode(no_pause).has_value()); + // A pause far too long + for (int32_t &item : timings) { + if (item < -3000) { + item = -9000; + } + } + EXPECT_FALSE(decode(timings).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 9eb21f30fc..8d9d850665 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -138,6 +138,11 @@ on_rc5: - logger.log: format: "on_rc5: %u %u" args: ["x.address", "x.command"] +on_rc5x: + then: + - logger.log: + format: "on_rc5x: %u %u %u" + args: ["x.address", "x.command", "x.extension"] on_rc6: then: - logger.log: diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index ad5eb72b04..bc9cb59420 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -15,6 +15,12 @@ binary_sensor: panasonic: address: 0x4004 command: 0x100BCBD + - platform: remote_receiver + name: RC5X Remote Input + rc5x: + address: 0x00 + command: 0x0D + extension: 0x03 - platform: remote_receiver name: Hob2Hood Light On hob2hood: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 97ce6a37ed..16a754d845 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -169,6 +169,13 @@ button: remote_transmitter.transmit_raw: code: !lambda |- return {(int32_t)id(remote_transmitter_test_number).state * 100, -1000}; + - platform: template + name: RC5X Marantz + on_press: + remote_transmitter.transmit_rc5x: + address: 0x10 + command: 0x0 + extension: 0x6 - platform: template name: AEHA id: eaha_hitachi_climate_power_on From ba9061e34e2696027a8078b5c651c223568d2e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:30:53 +0200 Subject: [PATCH 164/268] [nextion] Pass std::string to custom-protocol automations that defer (#20571) Co-authored-by: Claude Opus 5.5 --- esphome/components/nextion/display.py | 20 ++++++++ tests/component_tests/nextion/__init__.py | 0 .../nextion/config/custom_triggers.yaml | 21 +++++++++ .../nextion/test_custom_trigger_args.py | 47 +++++++++++++++++++ tests/components/nextion/common.yaml | 31 ++++++++---- 5 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/nextion/__init__.py create mode 100644 tests/component_tests/nextion/config/custom_triggers.yaml create mode 100644 tests/component_tests/nextion/test_custom_trigger_args.py diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 983a4777e8..969a6c8e8c 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -11,6 +11,7 @@ from esphome.const import ( PLATFORM_ESP8266, ) from esphome.core import CORE, TimePeriod +from esphome.types import ConfigType, TemplateArgsType from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import from .base_component import ( @@ -177,6 +178,10 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_BUFFER_OVERFLOW, "add_buffer_overflow_event_callback" ), +) + +# The strings live only while the callback runs +_CUSTOM_CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_CUSTOM_BINARY_SENSOR, "add_custom_binary_sensor_callback", @@ -293,3 +298,18 @@ async def to_code(config): cg.add_define(define_name) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + for entry in _CUSTOM_CALLBACK_AUTOMATIONS: + for conf in config.get(entry.conf_key, []): + await automation.build_callback_automation( + var, entry.callback_method, _owning_args(entry.args, conf), conf + ) + + +def _owning_args(args: TemplateArgsType, conf: ConfigType) -> TemplateArgsType: + """Pass std::string instead of StringRef to an automation that defers, as it keeps its arguments.""" + if not automation.has_non_synchronous_actions(conf): + return args + return [ + (cg.std_string if type_ is cg.StringRef else type_, name) + for type_, name in args + ] diff --git a/tests/component_tests/nextion/__init__.py b/tests/component_tests/nextion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/nextion/config/custom_triggers.yaml b/tests/component_tests/nextion/config/custom_triggers.yaml new file mode 100644 index 0000000000..6391a8babe --- /dev/null +++ b/tests/component_tests/nextion/config/custom_triggers.yaml @@ -0,0 +1,21 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +uart: + tx_pin: 17 + rx_pin: 16 + baud_rate: 115200 + +display: + - platform: nextion + on_custom_text_sensor: + - then: + - lambda: 'ESP_LOGD("test", "%s=%s", key.c_str(), value.c_str());' + - then: + - delay: 1s + - lambda: 'ESP_LOGD("test", "%s=%s", key.c_str(), value.c_str());' diff --git a/tests/component_tests/nextion/test_custom_trigger_args.py b/tests/component_tests/nextion/test_custom_trigger_args.py new file mode 100644 index 0000000000..765b7ec786 --- /dev/null +++ b/tests/component_tests/nextion/test_custom_trigger_args.py @@ -0,0 +1,47 @@ +"""Tests for the argument types of the nextion custom-protocol triggers.""" + +from collections.abc import Callable +from pathlib import Path + +import esphome.codegen as cg +from esphome.components.nextion.display import _owning_args +from esphome.core import Lambda + +ARGS = [(cg.StringRef, "key"), (cg.bool_, "value")] +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +DEFERRING_ACTION = {"delay": "1s"} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + + +def test_synchronous_automation_keeps_string_ref() -> None: + assert _owning_args(ARGS, {"then": [SYNCHRONOUS_ACTION]}) == ARGS + + +def test_deferring_automation_gets_std_string() -> None: + assert _owning_args(ARGS, {"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}) == [ + (cg.std_string, "key"), + (cg.bool_, "value"), + ] + + +def test_nested_deferring_action_gets_std_string() -> None: + actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] + assert _owning_args(ARGS, {"then": actions})[0] == (cg.std_string, "key") + + +def test_all_string_refs_swapped() -> None: + args = [(cg.StringRef, "key"), (cg.StringRef, "value")] + assert _owning_args(args, {"then": [DEFERRING_ACTION]}) == [ + (cg.std_string, "key"), + (cg.std_string, "value"), + ] + + +def test_generated_automation_types( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("custom_triggers.yaml")) + + assert "Automation" in main_cpp + assert "Automation" in main_cpp diff --git a/tests/components/nextion/common.yaml b/tests/components/nextion/common.yaml index 4f4347d89a..fc2c833e83 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -287,13 +287,20 @@ display: then: logger.log: "Nextion reported a buffer overflow!" on_custom_text_sensor: - then: - - lambda: |- - // key: StringRef, value: StringRef - if (key == "csv") { - // parse value here, or forward to your own component - ESP_LOGD("nextion.csv", "Got CSV: %s", value.c_str()); - } + - then: + - lambda: |- + // key: StringRef, value: StringRef + if (key == "csv") { + // parse value here, or forward to your own component + ESP_LOGD("nextion.csv", "Got CSV: %s", value.c_str()); + } + # Deferring: key and value are passed as std::string + - then: + - delay: 1s + - lambda: |- + if (key == "csv") { + ESP_LOGD("nextion.csv", "Got CSV earlier: %s", value.c_str()); + } on_custom_sensor: then: - lambda: |- @@ -308,9 +315,13 @@ display: ESP_LOGD("nextion.btn", "btn1=%s", ONOFF(value)); } on_custom_switch: - then: - - lambda: |- - ESP_LOGD("nextion.sw", "%s=%s", key.c_str(), ONOFF(value)); + - then: + - lambda: |- + ESP_LOGD("nextion.sw", "%s=%s", key.c_str(), ONOFF(value)); + - then: + - delay: 1s + - lambda: |- + ESP_LOGD("nextion.sw", "%s was %s", key.c_str(), ONOFF(value)); on_page: then: lambda: 'ESP_LOGD("display","Display shows new page %u", x);' From 92a4e283a923c68d61dd7435b192b836789dbf11 Mon Sep 17 00:00:00 2001 From: Zach Isbach Date: Sun, 11 Oct 2026 23:40:59 -0700 Subject: [PATCH 165/268] [epaper_spi] Add Good Display GDEY0213F51 4-color e-paper support (#17991) --- .../components/epaper_spi/models/jd79660.py | 36 +++++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 23 ++++++++++++ 2 files changed, 59 insertions(+) diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index ca824dccd4..a70d3fe522 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -118,3 +118,39 @@ jd79660.extend( (0x04,), ), ) + +# Good Display GDEY0213F51 +# +# Panel: 2.13" BWYR (4-color) e-paper, 122x250, controller JD79661 +# Init derived from Good Display sample +# +# Cross-checked against GxEPD2_213c_GDEY0213F51 +# +# +# Note: busy pin uses LOW=busy, HIGH=idle. +# +# fmt: off +jd79660.extend( + "goodisplay-gdey0213f51-2.13", + width=128, + height=250, + + initsequence=( + (0x4D, 0x78,), + (0x00, 0x0F, 0x29,), # PSR + (0x01, 0x07, 0x00,), # PWRR + (0x03, 0x10, 0x54, 0x44,), # POFS + (0x06, 0x05, 0x00, 0x3F, 0x0A, 0x25, 0x12, 0x1A,), # BTST_P + (0x50, 0x37,), # CDI + (0x60, 0x02, 0x02,), # TCON + (0x61, 128 // 256, 128 % 256, 250 // 256, 250 % 256,), # TRES: 128x250 fixed + (0xE7, 0x1C,), + (0xE3, 0x22,), + (0xB4, 0xD0,), + (0xB5, 0x03,), + (0xE9, 0x01,), + (0x30, 0x08,), + # Power On (0x04): Must be early part of init seq = Disabled later! + (0x04,), + ), +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 8cb2a99521..3839b4ff87 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -234,6 +234,29 @@ display: it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Good Display GDEY0213F51 4-color e-paper (128x250, BWYR, JD79661) + - platform: epaper_spi + spi_id: spi_bus + model: goodisplay-gdey0213f51-2.13 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0)); + it.circle(it.get_width() / 2, it.get_height() / 2, 10, Color(255, 255, 0)); + # Soldered Inkplate 2 3-color e-paper (104x212, BWR) - platform: epaper_spi spi_id: spi_bus From 7fbdcb0205a9ff96d774c7c72c2ad9ce57eb824b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:42:52 +0200 Subject: [PATCH 166/268] [emontx] Lock the emonTx across reboots (#20576) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/__init__.py | 5 ++++ esphome/components/emontx/emontx.cpp | 23 ++++++++++++++++- esphome/components/emontx/emontx.h | 6 +++++ tests/components/emontx/common.yaml | 1 + tests/components/emontx/emontx_test.cpp | 33 +++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 2146545db5..d6c46b5509 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -27,6 +27,7 @@ EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" +CONF_LOCK_ON_REBOOT = "lock_on_reboot" DOMAIN = "emontx" @@ -58,6 +59,7 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(EmonTx), cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(), cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(), + cv.Optional(CONF_LOCK_ON_REBOOT, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA) @@ -133,6 +135,9 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config[CONF_LOCK_ON_REBOOT]: + cg.add(var.set_lock_on_reboot(True)) + # Action: emontx.send_command diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index a307ac0de2..14dc89e56a 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -6,7 +6,27 @@ namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); -void EmonTx::setup() { this->buffer_pos_ = 0; } +void EmonTx::setup() { + this->buffer_pos_ = 0; + if (!this->lock_on_reboot_) + return; + // The line feed ends any boot noise first + ESP_LOGI(TAG, "Unlocking emonTx after boot"); + this->write_byte('\n'); + this->send_command("emonunlock"); +} + +void EmonTx::on_shutdown() { + if (!this->lock_on_reboot_) + return; + if (this->is_paused()) { + ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused"); + return; + } + ESP_LOGI(TAG, "Locking emonTx before shutdown"); + this->send_command("emonlock"); + this->flush(); +} /** * @brief Implements the main loop for parsing data from the serial port. @@ -97,6 +117,7 @@ void EmonTx::parse_json_(const char *data, size_t len) { */ void EmonTx::dump_config() { ESP_LOGCONFIG(TAG, "EmonTx:"); + ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_)); #ifdef USE_SENSOR ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 83ee4fdbab..bcec7ce171 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -33,6 +33,7 @@ class EmonTx final : public Component, public uart::UARTDevice { void loop() override; void setup() override; void dump_config() override; + void on_shutdown() override; template void add_on_json_callback(F &&callback) { this->json_callbacks_.add(std::forward(callback)); } @@ -48,6 +49,10 @@ class EmonTx final : public Component, public uart::UARTDevice { bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); } + /// Lock the emonTx ('emonlock') before a reboot, so serial garbage during the reboot is not taken + /// as a command, and unlock it in setup(). Needs emonTx firmware v1.2.0 or later. + void set_lock_on_reboot(bool lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } + #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } void register_sensor(const char *tag_name, sensor::Sensor *sensor); @@ -64,6 +69,7 @@ class EmonTx final : public Component, public uart::UARTDevice { uint16_t buffer_pos_{0}; std::atomic paused_{false}; bool was_paused_{false}; + bool lock_on_reboot_{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.yaml b/tests/components/emontx/common.yaml index 5c25e37abb..e609a217e0 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -8,6 +8,7 @@ button: emontx: id: test_emontx + lock_on_reboot: true on_json: - then: - logger.log: "Got JSON" diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 8530b0a003..5d9fa09611 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -158,4 +158,37 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { EXPECT_EQ(this->lines_[0], "{\"P1\":2}"); } +TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) { + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.set_lock_on_reboot(true); + this->sut_.on_shutdown(); + EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n"); +} + +// Firmware before v1.2.0 reads "emon..." as the bootloader command +TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.set_lock_on_reboot(true); + this->sut_.set_paused(true); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxTest, SetupUnlocksWhenEnabled) { + this->sut_.set_lock_on_reboot(true); + this->sut_.setup(); + EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n"); +} + +TEST_F(EmonTxTest, SetupSendsNothingByDefault) { + this->sut_.setup(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + } // namespace esphome::emontx::testing From c668ca516c6a110e3c4ec3f7732c8b5a3c71d3bc Mon Sep 17 00:00:00 2001 From: toups Date: Mon, 12 Oct 2026 02:48:55 -0400 Subject: [PATCH 167/268] [remote_base] updates to support 56 bit panasonic codes (#20260) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 47 ++++++-- .../remote_base/panasonic_protocol.cpp | 94 ++++++++-------- .../remote_base/panasonic_protocol.h | 9 +- tests/components/remote_base/__init__.py | 1 + .../components/remote_base/panasonic_test.cpp | 100 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 4 +- .../remote_receiver/esp32-common.yaml | 7 ++ .../remote_transmitter/common-buttons.yaml | 9 ++ .../components/remote_base/test_panasonic.py | 49 +++++++++ 9 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 tests/components/remote_base/panasonic_test.cpp create mode 100644 tests/unit_tests/components/remote_base/test_panasonic.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index cbe6aa8b49..1a767b723d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -59,6 +59,7 @@ AUTO_LOAD = ["binary_sensor"] DOMAIN = "remote_base" +CONF_ADDRESS_2 = "address_2" CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" @@ -2122,41 +2123,73 @@ register_protocol_action( ) = declare_protocol("Panasonic") PANASONIC_SCHEMA = cv.Schema( { + cv.Optional(CONF_NBITS, default=48): cv.one_of(48, 56, int=True), cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Optional(CONF_ADDRESS_2, default=0x00): cv.hex_uint8_t, cv.Required(CONF_COMMAND): cv.hex_uint32_t, } ) -@register_binary_sensor("panasonic", PanasonicBinarySensor, PANASONIC_SCHEMA) -def panasonic_binary_sensor(var, config): +def _panasonic_address_2_needs_56_bits(config: ConfigType) -> ConfigType: + # A 48-bit code has no second address byte; a lambda is only known on the device + address_2 = config[CONF_ADDRESS_2] + if config[CONF_NBITS] == 48 and isinstance(address_2, int) and address_2 != 0: + raise cv.Invalid( + f"{CONF_ADDRESS_2} is only sent with {CONF_NBITS}: 56", [CONF_ADDRESS_2] + ) + return config + + +# Transmit only +PANASONIC_ACTION_SCHEMA = PANASONIC_SCHEMA.extend( + { + cv.Optional(CONF_CARRIER_FREQUENCY, default="35000Hz"): cv.All( + cv.frequency, cv.uint32_t + ), + } +) + + +@register_binary_sensor( + "panasonic", + PanasonicBinarySensor, + cv.All(PANASONIC_SCHEMA, _panasonic_address_2_needs_56_bits), +) +def panasonic_binary_sensor(var: MockObj, config: ConfigType) -> None: cg.add( var.set_data( cg.StructInitializer( PanasonicData, ("address", config[CONF_ADDRESS]), ("command", config[CONF_COMMAND]), + ("nbits", config[CONF_NBITS]), + ("address_2", config[CONF_ADDRESS_2]), ) ) ) @register_trigger("panasonic", PanasonicTrigger, PanasonicData) -def panasonic_trigger(var, config): - pass +def panasonic_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" @register_dumper("panasonic", PanasonicDumper) -def panasonic_dumper(var, config): - pass +def panasonic_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" register_protocol_action( "panasonic", "Panasonic", - PANASONIC_SCHEMA, + PANASONIC_ACTION_SCHEMA, + (CONF_NBITS, "nbits", cg.uint8), (CONF_ADDRESS, "address", cg.uint16), + (CONF_ADDRESS_2, "address_2", cg.uint8), (CONF_COMMAND, "command", cg.uint32), + (CONF_CARRIER_FREQUENCY, "carrier_frequency", cg.uint32), + validate=_panasonic_address_2_needs_56_bits, ) diff --git a/esphome/components/remote_base/panasonic_protocol.cpp b/esphome/components/remote_base/panasonic_protocol.cpp index c2fc35d3ec..bedc9bca91 100644 --- a/esphome/components/remote_base/panasonic_protocol.cpp +++ b/esphome/components/remote_base/panasonic_protocol.cpp @@ -11,62 +11,72 @@ static constexpr uint32_t BIT_HIGH_US = 502; static constexpr uint32_t BIT_ZERO_LOW_US = 400; static constexpr uint32_t BIT_ONE_LOW_US = 1244; +static constexpr uint8_t NBITS_ADDRESS = 16; +static constexpr uint8_t NBITS_ADDRESS_2 = 8; +static constexpr uint8_t NBITS_COMMAND = 32; +static constexpr uint8_t NBITS_SHORT = NBITS_ADDRESS + NBITS_COMMAND; +static constexpr uint8_t NBITS_LONG = NBITS_SHORT + NBITS_ADDRESS_2; + +static void encode_bits(RemoteTransmitData *dst, uint32_t value, uint8_t nbits) { + for (uint32_t mask = 1UL << (nbits - 1); mask != 0; mask >>= 1) { + dst->item(BIT_HIGH_US, (value & mask) ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + } +} + +static bool decode_bits(RemoteReceiveData &src, uint32_t &value, uint8_t nbits) { + value = 0; + for (uint32_t mask = 1UL << (nbits - 1); mask != 0; mask >>= 1) { + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + value |= mask; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return false; + } + } + return true; +} + void PanasonicProtocol::encode(RemoteTransmitData *dst, const PanasonicData &data) { - dst->reserve(100); + if (data.nbits != NBITS_SHORT && data.nbits != NBITS_LONG) { + ESP_LOGW(TAG, "Unsupported nbits %u, expected 48 or 56", data.nbits); + return; + } + // Header, one item per bit, trailing mark + dst->reserve(3 + 2 * data.nbits); + dst->set_carrier_frequency(data.carrier_frequency); dst->item(HEADER_HIGH_US, HEADER_LOW_US); - dst->set_carrier_frequency(35000); - - uint32_t mask; - for (mask = 1UL << 15; mask != 0; mask >>= 1) { - if (data.address & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } - } - - for (mask = 1UL << 31; mask != 0; mask >>= 1) { - if (data.command & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } + encode_bits(dst, data.address, NBITS_ADDRESS); + if (data.nbits == NBITS_LONG) { + encode_bits(dst, data.address_2, NBITS_ADDRESS_2); } + encode_bits(dst, data.command, NBITS_COMMAND); dst->mark(BIT_HIGH_US); } + optional PanasonicProtocol::decode(RemoteReceiveData src) { - PanasonicData out{ - .address = 0, - .command = 0, - }; if (!src.expect_item(HEADER_HIGH_US, HEADER_LOW_US)) return {}; - uint32_t mask; - for (mask = 1UL << 15; mask != 0; mask >>= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - out.address |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - out.address &= ~mask; - } else { - return {}; - } - } + uint32_t address; + uint32_t word; + if (!decode_bits(src, address, NBITS_ADDRESS) || !decode_bits(src, word, NBITS_COMMAND)) + return {}; - for (mask = 1UL << 31; mask != 0; mask >>= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - out.command |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - out.command &= ~mask; - } else { - return {}; - } + PanasonicData out{}; + out.address = address; + uint32_t tail; + if (decode_bits(src, tail, NBITS_ADDRESS_2)) { + out.nbits = NBITS_LONG; + out.address_2 = word >> 24; + out.command = (word << 8) | tail; + } else { + out.command = word; } - return out; } + void PanasonicProtocol::dump(const PanasonicData &data) { - ESP_LOGI(TAG, "Received Panasonic: address=0x%04X, command=0x%08" PRIX32, data.address, data.command); + ESP_LOGI(TAG, "Received Panasonic: nbits=%u, address=0x%04X, address_2=0x%02X, command=0x%08" PRIX32, data.nbits, + data.address, data.address_2, data.command); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/panasonic_protocol.h b/esphome/components/remote_base/panasonic_protocol.h index da8e3a0ff7..b560875982 100644 --- a/esphome/components/remote_base/panasonic_protocol.h +++ b/esphome/components/remote_base/panasonic_protocol.h @@ -8,10 +8,17 @@ namespace esphome::remote_base { struct PanasonicData { + // address and command stay first so positional initializers written for the 48-bit struct keep working. uint16_t address; uint32_t command; + uint8_t nbits{48}; // 48 or 56 + uint8_t address_2{0}; + // Transmit only: a receiver cannot measure the carrier, so it is not compared. + uint32_t carrier_frequency{35000}; - bool operator==(const PanasonicData &rhs) const { return address == rhs.address && command == rhs.command; } + bool operator==(const PanasonicData &rhs) const { + return address == rhs.address && address_2 == rhs.address_2 && command == rhs.command && nbits == rhs.nbits; + } }; class PanasonicProtocol : public RemoteProtocol { diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 927ff02252..31ecbc3eac 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -15,6 +15,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "keeloq", "nexus", "rc5x", + "panasonic", ): request_protocol(protocol) diff --git a/tests/components/remote_base/panasonic_test.cpp b/tests/components/remote_base/panasonic_test.cpp new file mode 100644 index 0000000000..dfdf0e5069 --- /dev/null +++ b/tests/components/remote_base/panasonic_test.cpp @@ -0,0 +1,100 @@ +#include +#include "esphome/components/remote_base/panasonic_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +const PanasonicData FRAME_48{0x4004, 0x0100BCBD}; +const PanasonicData FRAME_56{0x4004, 0x0100BCBD, 56, 0x12}; + +RawTimings encode(const PanasonicData &data) { + RemoteTransmitData tx; + PanasonicProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return PanasonicProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +} // namespace + +TEST(PanasonicProtocolTest, ForwardsTheConfiguredCarrier) { + PanasonicData data = FRAME_56; + data.carrier_frequency = 36000; + RemoteTransmitData tx; + PanasonicProtocol().encode(&tx, data); + EXPECT_EQ(tx.get_carrier_frequency(), 36000u); +} + +TEST(PanasonicProtocolTest, RoundTrips48BitFrame) { + RemoteTransmitData tx; + PanasonicProtocol().encode(&tx, FRAME_48); + EXPECT_EQ(tx.get_data().size(), 2u + 2u * 48u + 1u); + EXPECT_EQ(tx.get_carrier_frequency(), 35000u); + auto decoded = decode(tx.get_data()); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_48); +} + +TEST(PanasonicProtocolTest, RoundTrips56BitFrame) { + RawTimings timings = encode(FRAME_56); + EXPECT_EQ(timings.size(), 2u + 2u * 56u + 1u); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_56); +} + +TEST(PanasonicProtocolTest, Decodes56BitFrameWithTrailingItems) { + RawTimings timings = encode(FRAME_56); + timings.insert(timings.end(), {-20000, 3502, -1750, 502, -400}); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_56); +} + +TEST(PanasonicProtocolTest, Decodes48BitFrameWithTrailingItems) { + RawTimings timings = encode(FRAME_48); + // A repeat frame arriving inside the idle window. + timings.insert(timings.end(), {-20000, 3502, -1750, 502, -400}); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(*decoded, FRAME_48); +} + +TEST(PanasonicProtocolTest, RejectsTruncatedFrame) { + RawTimings timings = encode(FRAME_48); + timings.resize(timings.size() - 4); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(PanasonicProtocolTest, InvalidNbitsSendsNothing) { + PanasonicData data = FRAME_48; + data.nbits = 40; + EXPECT_TRUE(encode(data).empty()); +} + +TEST(PanasonicProtocolTest, EqualityIgnoresCarrier) { + PanasonicData other_carrier = FRAME_56; + other_carrier.carrier_frequency = 36000; + EXPECT_EQ(FRAME_56, other_carrier); + PanasonicData other_address_2 = FRAME_56; + other_address_2.address_2 = 0x13; + EXPECT_NE(FRAME_56, other_address_2); + EXPECT_NE(FRAME_48, FRAME_56); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 8d9d850665..fca5b0c374 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -116,8 +116,8 @@ on_onkyori: on_panasonic: then: - logger.log: - format: "on_panasonic: %u %lu" - args: ["x.address", "long(x.command)"] + format: "on_panasonic: %u %u %u %lu" + args: ["x.nbits", "x.address", "x.address_2", "long(x.command)"] on_pioneer: then: - logger.log: diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index bc9cb59420..da9a0fc963 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -21,6 +21,13 @@ binary_sensor: address: 0x00 command: 0x0D extension: 0x03 + - platform: remote_receiver + name: Panasonic 56 bit Remote Input + panasonic: + nbits: 56 + address: 0x4004 + address_2: 0x12 + command: 0x100BCBD - platform: remote_receiver name: Hob2Hood Light On hob2hood: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 16a754d845..2ef158456e 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -103,6 +103,15 @@ button: remote_transmitter.transmit_panasonic: address: 0x4004 command: 0x1000BCD + - platform: template + name: Panasonic 56 bit + on_press: + remote_transmitter.transmit_panasonic: + nbits: 56 + address: 0x4004 + address_2: 0x12 + command: !lambda return 0x1000BCD; + carrier_frequency: 36kHz - platform: template name: Pioneer on_press: diff --git a/tests/unit_tests/components/remote_base/test_panasonic.py b/tests/unit_tests/components/remote_base/test_panasonic.py new file mode 100644 index 0000000000..3f0e8794c0 --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_panasonic.py @@ -0,0 +1,49 @@ +"""A Panasonic address_2 is only sent with a 56-bit code.""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.remote_base import ( + CONF_ADDRESS_2, + PANASONIC_SCHEMA, + _panasonic_address_2_needs_56_bits, +) +import esphome.config_validation as cv +from esphome.const import CONF_NBITS +from esphome.core import Lambda + +BASE = {"address": 0x4004, "command": 0x0100BCBD} + + +def test_address_2_needs_56_bits() -> None: + config = PANASONIC_SCHEMA({**BASE, CONF_NBITS: 56, CONF_ADDRESS_2: 0x12}) + assert _panasonic_address_2_needs_56_bits(config)[CONF_ADDRESS_2] == 0x12 + with pytest.raises(cv.Invalid, match="only sent with nbits: 56"): + _panasonic_address_2_needs_56_bits( + PANASONIC_SCHEMA({**BASE, CONF_NBITS: 48, CONF_ADDRESS_2: 0x12}) + ) + + +def test_zero_address_2_is_fine_with_48_bits() -> None: + config = PANASONIC_SCHEMA({**BASE, CONF_ADDRESS_2: 0}) + assert _panasonic_address_2_needs_56_bits(config)[CONF_NBITS] == 48 + + +def test_lambda_address_2_is_not_checked_here() -> None: + config = {**BASE, CONF_NBITS: 48, CONF_ADDRESS_2: Lambda("return 0x12;")} + assert isinstance( + _panasonic_address_2_needs_56_bits(config)[CONF_ADDRESS_2], Lambda + ) + + +def test_transmit_action_checks_address_2() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_panasonic"].raw_schema + assert schema({**BASE, CONF_NBITS: 56, CONF_ADDRESS_2: 0x12})[CONF_NBITS] == 56 + assert isinstance( + schema({**BASE, CONF_ADDRESS_2: Lambda("return 0x12;")})[CONF_ADDRESS_2], + Lambda, + ) + with pytest.raises(cv.Invalid, match="only sent with nbits: 56"): + schema({**BASE, CONF_ADDRESS_2: 0x12}) From 20cdca6f1871a52521d65103bb020881deb1eac9 Mon Sep 17 00:00:00 2001 From: Brad Date: Mon, 12 Oct 2026 00:01:12 -0700 Subject: [PATCH 168/268] [remote_base] Decode the full MagiQuest frame and check its checksum (#10622) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 18 ++- .../remote_base/magiquest_protocol.cpp | 60 ++++++--- .../remote_base/magiquest_protocol.h | 17 +-- tests/components/remote_base/__init__.py | 1 + .../components/remote_base/magiquest_test.cpp | 126 ++++++++++++++++++ .../remote_receiver/esp32-common.yaml | 7 + 6 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 tests/components/remote_base/magiquest_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 1a767b723d..5b6cb33dec 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -953,16 +953,22 @@ MAGIQUEST_SCHEMA = cv.Schema( } ) +# A wand_id of 0 matches any wand. Magnitude is still accepted but not matched on. +MAGIQUEST_BINARY_SENSOR_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WAND_ID, default=0): cv.hex_uint32_t, + cv.Optional(CONF_MAGNITUDE): cv.hex_uint16_t, + } +) -@register_binary_sensor("magiquest", MagiQuestBinarySensor, MAGIQUEST_SCHEMA) + +@register_binary_sensor( + "magiquest", MagiQuestBinarySensor, MAGIQUEST_BINARY_SENSOR_SCHEMA +) def magiquest_binary_sensor(var, config): cg.add( var.set_data( - cg.StructInitializer( - MagiQuestData, - ("magnitude", config[CONF_MAGNITUDE]), - ("wand_id", config[CONF_WAND_ID]), - ) + cg.StructInitializer(MagiQuestData, ("wand_id", config[CONF_WAND_ID])) ) ) diff --git a/esphome/components/remote_base/magiquest_protocol.cpp b/esphome/components/remote_base/magiquest_protocol.cpp index 5b47f7e438..71a6a5ace1 100644 --- a/esphome/components/remote_base/magiquest_protocol.cpp +++ b/esphome/components/remote_base/magiquest_protocol.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" /* Based on protocol analysis from - * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8cpp_source.html + * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8hpp_source.html */ namespace esphome::remote_base { @@ -10,12 +10,25 @@ namespace esphome::remote_base { ESPHOME_LOG_TAG(TAG, "remote.magiquest"); static constexpr uint32_t MAGIQUEST_UNIT = 288; // us +// Half a unit is the widest window that still tells a one unit mark or space from a two unit one. +static constexpr uint32_t MAGIQUEST_TOLERANCE = MAGIQUEST_UNIT / 2; static constexpr uint32_t MAGIQUEST_ONE_MARK = 2 * MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ONE_SPACE = 2 * MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ZERO_MARK = MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ZERO_SPACE = 3 * MAGIQUEST_UNIT; +static bool checksum_is_valid(uint32_t wand_id, uint32_t magnitude_and_checksum) { + // The frame's six payload bytes, which start at the wand id's top bit, must sum to zero. + const uint32_t id_bytes = wand_id << 1; + uint8_t sum = 0; + for (uint8_t shift = 0; shift < 32; shift += 8) { + sum += (id_bytes >> shift) + (magnitude_and_checksum >> shift); + } + return sum == 0; +} + void MagiQuestProtocol::encode(RemoteTransmitData *dst, const MagiQuestData &data) { + // Still the old frame layout, which the decoder no longer reads; changing it would alter what existing configs send. dst->reserve(101); // 2 start bits, 48 data bits, 1 stop bit dst->set_carrier_frequency(38000); @@ -41,41 +54,48 @@ void MagiQuestProtocol::encode(RemoteTransmitData *dst, const MagiQuestData &dat dst->mark(MAGIQUEST_UNIT); } optional MagiQuestProtocol::decode(RemoteReceiveData src) { - MagiQuestData data{ - .magnitude = 0, - .wand_id = 0, - }; - // Two start bits - if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE) || - !src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - return {}; + src.set_tolerance(MAGIQUEST_TOLERANCE, TOLERANCE_MODE_TIME); + + MagiQuestData data{}; + + // 8 bit header, always zero + for (uint8_t i = 0; i < 8; i++) { + if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { + return {}; + } } - for (uint32_t mask = 1 << 31; mask; mask >>= 1) { + // 31 bit wand id + for (uint32_t mask = 1 << 30; mask; mask >>= 1) { if (src.expect_item(MAGIQUEST_ONE_MARK, MAGIQUEST_ONE_SPACE)) { data.wand_id |= mask; - } else if (src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - data.wand_id &= ~mask; - } else { + } else if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { return {}; } } - for (uint16_t mask = 1 << 15; mask; mask >>= 1) { + // 9 bit magnitude, then 8 bit checksum + uint32_t magnitude_and_checksum = 0; + for (uint32_t mask = 1 << 16; mask > 1; mask >>= 1) { if (src.expect_item(MAGIQUEST_ONE_MARK, MAGIQUEST_ONE_SPACE)) { - data.magnitude |= mask; - } else if (src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - data.magnitude &= ~mask; - } else { + magnitude_and_checksum |= mask; + } else if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { return {}; } } + // The last bit has no trailing space. A wrong or missing mark here is caught by the checksum. + if (src.expect_mark(MAGIQUEST_ONE_MARK)) { + magnitude_and_checksum |= 1; + } - src.expect_mark(MAGIQUEST_UNIT); + if (!checksum_is_valid(data.wand_id, magnitude_and_checksum)) { + return {}; + } + data.magnitude = magnitude_and_checksum >> 8; return data; } void MagiQuestProtocol::dump(const MagiQuestData &data) { - ESP_LOGI(TAG, "Received MagiQuest: wand_id=0x%08" PRIX32 ", magnitude=0x%04X", data.wand_id, data.magnitude); + ESP_LOGI(TAG, "Received MagiQuest: wand_id=0x%08" PRIX32 ", magnitude=%u", data.wand_id, data.magnitude); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/magiquest_protocol.h b/esphome/components/remote_base/magiquest_protocol.h index 98a5fe2081..4e88f06cd4 100644 --- a/esphome/components/remote_base/magiquest_protocol.h +++ b/esphome/components/remote_base/magiquest_protocol.h @@ -5,23 +5,24 @@ #include /* Based on protocol analysis from - * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8cpp_source.html + * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8hpp_source.html */ namespace esphome::remote_base { +// Older decoders read the wand id 5 bits too early, so ids in older configs lack its 5 low bits. +static constexpr uint8_t MAGIQUEST_LEGACY_WAND_ID_SHIFT = 5; + struct MagiQuestData { uint16_t magnitude; uint32_t wand_id; + // Not symmetric: `this` is the decoded frame and `rhs` the configured match. A configured wand_id of 0 matches + // any wand. Magnitude is not compared; it appears to encode the cast type rather than a strength. The legacy + // match applies to every configured id, so an id below 2^26 also matches the 32 wands whose full id shifts to it. bool operator==(const MagiQuestData &rhs) const { - // Treat 0xffff as a special, wildcard magnitude - // In testing, the wand never produces this value, and this allows us to match - // on just the wand_id if wanted. - if (rhs.wand_id != this->wand_id) { - return false; - } - return (this->wand_id == 0xffff || rhs.wand_id == 0xffff || this->wand_id == rhs.wand_id); + return rhs.wand_id == 0 || rhs.wand_id == this->wand_id || + rhs.wand_id == (this->wand_id >> MAGIQUEST_LEGACY_WAND_ID_SHIFT); } }; diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 31ecbc3eac..82840b5c2b 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "aeha", "keeloq", "nexus", + "magiquest", "rc5x", "panasonic", ): diff --git a/tests/components/remote_base/magiquest_test.cpp b/tests/components/remote_base/magiquest_test.cpp new file mode 100644 index 0000000000..3641f1ec80 --- /dev/null +++ b/tests/components/remote_base/magiquest_test.cpp @@ -0,0 +1,126 @@ +#include +#include "esphome/components/remote_base/magiquest_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr int32_t UNIT = 288; +constexpr uint32_t WAND_ID = 0x2A5B3C1D; +constexpr uint16_t MAGNITUDE = 258; +constexpr MagiQuestData DECODED{.magnitude = MAGNITUDE, .wand_id = WAND_ID}; + +void add_bit(RawTimings &timings, bool one, bool last = false) { + timings.push_back(one ? 2 * UNIT : UNIT); + if (!last) { + timings.push_back(-(one ? 2 * UNIT : 3 * UNIT)); + } +} + +// Overwrite bit `bit` of a built frame with a one +void set_one(RawTimings &timings, size_t bit) { + timings[2 * bit] = 2 * UNIT; + timings[2 * bit + 1] = -2 * UNIT; +} + +// Build a frame the way a wand sends it: 8 zero header bits, a 31 bit wand id, a 9 bit magnitude and a checksum +// that makes the six payload bytes sum to zero. The last bit has no trailing space. +RawTimings build_frame(uint32_t wand_id, uint16_t magnitude, uint8_t checksum_offset = 0) { + const uint32_t id_bytes = wand_id << 1; + const uint32_t magnitude_bits = uint32_t(magnitude) << 8; + uint8_t sum = 0; + for (uint8_t shift = 0; shift < 32; shift += 8) { + sum += (id_bytes >> shift) + (magnitude_bits >> shift); + } + const uint32_t magnitude_and_checksum = magnitude_bits | uint8_t(-sum + checksum_offset); + + RawTimings timings; + for (int i = 0; i < 8; i++) { + add_bit(timings, false); + } + for (uint32_t mask = 1 << 30; mask; mask >>= 1) { + add_bit(timings, wand_id & mask); + } + for (uint32_t mask = 1 << 16; mask; mask >>= 1) { + add_bit(timings, magnitude_and_checksum & mask, mask == 1); + } + return timings; +} + +// The receiver's tolerance is passed for completeness; decode() replaces it with the protocol's fixed window +optional decode(const RawTimings &timings) { + return MagiQuestProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_decodes_to(const RawTimings &timings, uint32_t wand_id, uint16_t magnitude) { + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + if (decoded.has_value()) { + EXPECT_EQ(decoded->wand_id, wand_id); + EXPECT_EQ(decoded->magnitude, magnitude); + } +} + +} // namespace + +TEST(MagiQuestProtocolTest, DecodesAValidFrame) { + expect_decodes_to(build_frame(WAND_ID, MAGNITUDE), WAND_ID, MAGNITUDE); +} + +// Magnitude 1 with wand id 0 needs a checksum of 0xFF, so the last bit is a one; magnitude 2 needs 0xFE. +TEST(MagiQuestProtocolTest, DecodesBothValuesOfTheLastBit) { + expect_decodes_to(build_frame(0, 1), 0, 1); + expect_decodes_to(build_frame(0, 2), 0, 2); +} + +TEST(MagiQuestProtocolTest, RejectsABadChecksum) { + EXPECT_FALSE(decode(build_frame(WAND_ID, MAGNITUDE, 1)).has_value()); +} + +TEST(MagiQuestProtocolTest, RejectsAFlippedDataBit) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + set_one(timings, 8); // first wand id bit, a zero in WAND_ID + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(MagiQuestProtocolTest, RejectsANonZeroHeader) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + set_one(timings, 0); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(MagiQuestProtocolTest, RejectsATruncatedFrame) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + timings.resize(timings.size() - 10); + EXPECT_FALSE(decode(timings).has_value()); +} + +// Receivers stretch marks and shrink spaces; 120 us is more than 25% of a unit but within the fixed window. +TEST(MagiQuestProtocolTest, DecodesWithReceiverJitterUnderTheDefaultTolerance) { + auto timings = build_frame(WAND_ID, MAGNITUDE); + for (auto &timing : timings) { + timing += 120; + } + expect_decodes_to(timings, WAND_ID, MAGNITUDE); +} + +TEST(MagiQuestProtocolTest, MatchesAConfiguredWand) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = WAND_ID})); + EXPECT_FALSE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = WAND_ID + 1})); +} + +TEST(MagiQuestProtocolTest, MatchesAnyWandWhenNoIdIsConfigured) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = 0})); +} + +// Configs written for the old decoder hold the id without its 5 low bits. +TEST(MagiQuestProtocolTest, MatchesAnIdFromTheOldDecoder) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0, .wand_id = WAND_ID >> 5})); +} + +TEST(MagiQuestProtocolTest, IgnoresMagnitudeWhenMatching) { + EXPECT_TRUE(DECODED == (MagiQuestData{.magnitude = 0xFFFF, .wand_id = WAND_ID})); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index da9a0fc963..f66c2ed3b0 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -32,6 +32,13 @@ binary_sensor: name: Hob2Hood Light On hob2hood: command: light_on + - platform: remote_receiver + name: MagiQuest Wand + magiquest: + wand_id: 0x0F1E2D3C + - platform: remote_receiver + name: MagiQuest Any Wand + magiquest: {} - platform: remote_receiver name: Nexus Sensor nexus: From d265ed2b9e1a4fd27accc9df1a9369bffc48edad Mon Sep 17 00:00:00 2001 From: Siddharth Singh <148135273+Siddharthsinghkumar@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:59:09 +0530 Subject: [PATCH 169/268] [remote_base] Fix NEC command_repeats to send repeat frames (#17663) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 3 +- .../components/remote_base/nec_protocol.cpp | 59 ++++---- tests/components/remote_base/__init__.py | 1 + .../remote_base/nec_protocol_test.cpp | 132 ++++++++++++++++++ 4 files changed, 159 insertions(+), 36 deletions(-) create mode 100644 tests/components/remote_base/nec_protocol_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 5b6cb33dec..a664a6bdaf 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1076,7 +1076,8 @@ NEC_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint16_t, cv.Required(CONF_COMMAND): cv.hex_uint16_t, - cv.Optional(CONF_COMMAND_REPEATS, default=1): cv.uint16_t, + # Non-RMT transmitters hold an interrupt lock for the whole send, ~108 ms per extra repeat + cv.Optional(CONF_COMMAND_REPEATS, default=1): cv.int_range(min=1, max=10), } ) diff --git a/esphome/components/remote_base/nec_protocol.cpp b/esphome/components/remote_base/nec_protocol.cpp index 3edb50141c..c4ba8c5ebc 100644 --- a/esphome/components/remote_base/nec_protocol.cpp +++ b/esphome/components/remote_base/nec_protocol.cpp @@ -1,4 +1,5 @@ #include "nec_protocol.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::remote_base { @@ -10,35 +11,42 @@ static constexpr uint32_t HEADER_LOW_US = 4500; static constexpr uint32_t BIT_HIGH_US = 560; static constexpr uint32_t BIT_ONE_LOW_US = 1690; static constexpr uint32_t BIT_ZERO_LOW_US = 560; +static constexpr uint32_t REPEAT_HEADER_LOW_US = HEADER_LOW_US / 2; +// A held button repeats every 108 ms: the first gap depends on the frame's own length, later ones follow a repeat frame +static constexpr uint32_t REPEAT_PERIOD_US = 108000; +static constexpr uint32_t REPEAT_FRAME_US = HEADER_HIGH_US + REPEAT_HEADER_LOW_US + BIT_HIGH_US; +static constexpr uint32_t REPEAT_GAP_US = REPEAT_PERIOD_US - REPEAT_FRAME_US; +// Transmitters without an RMT peripheral hold an interrupt lock for the whole send, about 108 ms per repeat +static constexpr uint16_t MAX_COMMAND_REPEATS = 10; void NECProtocol::encode(RemoteTransmitData *dst, const NECData &data) { ESP_LOGD(TAG, "Sending NEC: address=0x%04X, command=0x%04X command_repeats=%d", data.address, data.command, data.command_repeats); - dst->reserve(2 + 32 + 32 * data.command_repeats + 2); + // The schema bounds literal values; a lambda can produce anything, so clamp here too + const uint16_t repeats = clamp(data.command_repeats, 1, MAX_COMMAND_REPEATS); + // Frame: header pair, 32 bit pairs, stop mark. Each extra repeat: gap space, header pair, mark. + dst->reserve(2 + 64 + 1 + (repeats - 1) * 4); dst->set_carrier_frequency(38000); dst->item(HEADER_HIGH_US, HEADER_LOW_US); + uint32_t frame_us = HEADER_HIGH_US + HEADER_LOW_US + BIT_HIGH_US; - for (uint16_t mask = 1; mask; mask <<= 1) { - if (data.address & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } - } - - for (uint16_t repeats = 0; repeats < data.command_repeats; repeats++) { + for (uint16_t value : {data.address, data.command}) { for (uint16_t mask = 1; mask; mask <<= 1) { - if (data.command & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } + const uint32_t low_us = (value & mask) ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US; + dst->item(BIT_HIGH_US, low_us); + frame_us += BIT_HIGH_US + low_us; } } dst->mark(BIT_HIGH_US); + + for (uint16_t repeat = 1; repeat < repeats; repeat++) { + dst->space(repeat == 1 ? REPEAT_PERIOD_US - frame_us : REPEAT_GAP_US); + dst->item(HEADER_HIGH_US, REPEAT_HEADER_LOW_US); + dst->mark(BIT_HIGH_US); + } } optional NECProtocol::decode(RemoteReceiveData src) { NECData data{ @@ -69,26 +77,7 @@ optional NECProtocol::decode(RemoteReceiveData src) { } } - while (src.peek_item(BIT_HIGH_US, BIT_ONE_LOW_US) || src.peek_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - uint16_t command = 0; - for (uint16_t mask = 1; mask; mask <<= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - command |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - command &= ~mask; - } else { - return {}; - } - } - - // Make sure the extra/repeated data matches original command - if (command != data.command) { - return {}; - } - - data.command_repeats += 1; - } - + // Repeat frames arrive as separate captures (receiver idle is far below the repeat gap) and are not decoded src.expect_mark(BIT_HIGH_US); return data; } diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 82840b5c2b..cddee51195 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "aeha", "keeloq", "nexus", + "nec", "magiquest", "rc5x", "panasonic", diff --git a/tests/components/remote_base/nec_protocol_test.cpp b/tests/components/remote_base/nec_protocol_test.cpp new file mode 100644 index 0000000000..6b6914ce9c --- /dev/null +++ b/tests/components/remote_base/nec_protocol_test.cpp @@ -0,0 +1,132 @@ +#include +#include +#include "esphome/components/remote_base/nec_protocol.h" + +namespace esphome::remote_base::testing { + +// NEC timings from the spec (esphome/issues#6603), independent of the encoder constants +static constexpr int32_t HEADER_MARK = 9000; +static constexpr int32_t HEADER_SPACE = -4500; +static constexpr int32_t REPEAT_HEADER_SPACE = -2250; +static constexpr int32_t BIT_MARK = 560; +static constexpr int32_t ONE_SPACE = -1690; +static constexpr int32_t ZERO_SPACE = -560; +static constexpr int32_t REPEAT_PERIOD = 108000; +static constexpr int32_t REPEAT_GAP = -(REPEAT_PERIOD - (HEADER_MARK - REPEAT_HEADER_SPACE + BIT_MARK)); + +// Builds a full NEC frame by hand: header, address and command LSB first, stop mark +static std::vector build_frame(uint16_t address, uint16_t command) { + std::vector raw{HEADER_MARK, HEADER_SPACE}; + for (uint16_t value : {address, command}) { + for (int bit = 0; bit < 16; bit++) { + raw.push_back(BIT_MARK); + raw.push_back((value >> bit) & 1 ? ONE_SPACE : ZERO_SPACE); + } + } + raw.push_back(BIT_MARK); + return raw; +} + +/// The space that puts the first repeat frame 108 ms after the start of this frame +static int32_t first_repeat_gap(const std::vector &frame) { + int32_t duration = 0; + for (int32_t item : frame) { + duration += item < 0 ? -item : item; + } + return -(REPEAT_PERIOD - duration); +} + +TEST(NECProtocolTest, EncodeWithoutRepeats) { + NECProtocol protocol; + RemoteTransmitData dst; + protocol.encode(&dst, NECData{.address = 0x7F80, .command = 0xF20D, .command_repeats = 1}); + + EXPECT_EQ(dst.get_carrier_frequency(), 38000u); + EXPECT_EQ(dst.get_data(), build_frame(0x7F80, 0xF20D)); +} + +TEST(NECProtocolTest, EncodeWithRepeats) { + NECProtocol protocol; + RemoteTransmitData dst; + protocol.encode(&dst, NECData{.address = 0x7F80, .command = 0xF20D, .command_repeats = 3}); + + std::vector expected = build_frame(0x7F80, 0xF20D); + expected.insert(expected.end(), {first_repeat_gap(expected), HEADER_MARK, REPEAT_HEADER_SPACE, BIT_MARK}); + expected.insert(expected.end(), {REPEAT_GAP, HEADER_MARK, REPEAT_HEADER_SPACE, BIT_MARK}); + EXPECT_EQ(dst.get_data(), expected); +} + +TEST(NECProtocolTest, FirstRepeatGapFollowsFrameLength) { + NECProtocol protocol; + RemoteTransmitData all_zeros; + protocol.encode(&all_zeros, NECData{.address = 0, .command = 0, .command_repeats = 2}); + RemoteTransmitData all_ones; + protocol.encode(&all_ones, NECData{.address = 0xFFFF, .command = 0xFFFF, .command_repeats = 2}); + + const std::vector zeros_frame = build_frame(0, 0); + const std::vector ones_frame = build_frame(0xFFFF, 0xFFFF); + EXPECT_EQ(all_zeros.get_data()[zeros_frame.size()], first_repeat_gap(zeros_frame)); + EXPECT_EQ(all_ones.get_data()[ones_frame.size()], first_repeat_gap(ones_frame)); + // Both frames start their repeat 108 ms after they began + EXPECT_EQ(first_repeat_gap(zeros_frame) - first_repeat_gap(ones_frame), + static_cast(32 * (ONE_SPACE - ZERO_SPACE))); +} + +TEST(NECProtocolTest, EncodeClampsRepeats) { + NECProtocol protocol; + RemoteTransmitData none; + protocol.encode(&none, NECData{.address = 0x1234, .command = 0x5678, .command_repeats = 0}); + EXPECT_EQ(none.get_data().size(), 67u); + + RemoteTransmitData many; + protocol.encode(&many, NECData{.address = 0x1234, .command = 0x5678, .command_repeats = 500}); + EXPECT_EQ(many.get_data().size(), 67u + 4u * 9u); +} + +TEST(NECProtocolTest, EncodeReserveMatchesSize) { + NECProtocol protocol; + RemoteTransmitData dst; + protocol.encode(&dst, NECData{.address = 0x1234, .command = 0x5678, .command_repeats = 5}); + + EXPECT_EQ(dst.get_data().size(), 67u + 4u * 4u); + EXPECT_GE(dst.get_data().capacity(), dst.get_data().size()); +} + +TEST(NECProtocolTest, DecodeFullFrame) { + NECProtocol protocol; + std::vector raw = build_frame(0x1234, 0x5678); + RemoteReceiveData rx(raw, 25, TOLERANCE_MODE_PERCENTAGE); + auto decoded = protocol.decode(rx); + + ASSERT_TRUE(decoded.has_value()); + // clang-tidy's unchecked-optional-access does not model gtest's ASSERT_TRUE as a check + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234); + EXPECT_EQ(decoded->command, 0x5678); + EXPECT_EQ(decoded->command_repeats, 1); + } +} + +TEST(NECProtocolTest, DecodeWithoutStopMark) { + NECProtocol protocol; + std::vector raw = build_frame(0x1234, 0x5678); + raw.pop_back(); + RemoteReceiveData rx(raw, 25, TOLERANCE_MODE_PERCENTAGE); + auto decoded = protocol.decode(rx); + + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234); + EXPECT_EQ(decoded->command, 0x5678); + } +} + +TEST(NECProtocolTest, DecodeStandaloneRepeatFrameRejected) { + NECProtocol protocol; + std::vector raw{HEADER_MARK, REPEAT_HEADER_SPACE, BIT_MARK}; + RemoteReceiveData rx(raw, 25, TOLERANCE_MODE_PERCENTAGE); + + EXPECT_FALSE(protocol.decode(rx).has_value()); +} + +} // namespace esphome::remote_base::testing From 061b27af02c90da834f2a48fce50c1a4a2fcde42 Mon Sep 17 00:00:00 2001 From: marshn Date: Mon, 12 Oct 2026 08:54:55 +0100 Subject: [PATCH 170/268] [remote_base] Add a data option and checksum handling to the Drayton protocol (#13060) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 30 +++- .../remote_base/drayton_protocol.cpp | 165 +++++++++--------- .../components/remote_base/drayton_protocol.h | 6 +- tests/components/remote_base/__init__.py | 1 + tests/components/remote_base/drayton_test.cpp | 141 +++++++++++++++ .../remote_receiver/common-actions.yaml | 4 +- .../remote_receiver/esp32-common.yaml | 11 ++ .../remote_transmitter/common-buttons.yaml | 7 + 8 files changed, 273 insertions(+), 92 deletions(-) create mode 100644 tests/components/remote_base/drayton_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index a664a6bdaf..b0dc93e3b7 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1501,22 +1501,34 @@ async def raw_action(var, config, args): ) = declare_protocol("Drayton") DRAYTON_SCHEMA = cv.Schema( { - cv.Required(CONF_ADDRESS): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), - cv.Required(CONF_CHANNEL): cv.All(cv.hex_int, cv.Range(min=0, max=0x1F)), - cv.Required(CONF_COMMAND): cv.All(cv.hex_int, cv.Range(min=0, max=0x7F)), + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Optional(CONF_DATA): cv.hex_int_range(min=0, max=0xF), + cv.Optional(CONF_CHANNEL): cv.hex_int_range(min=0, max=0x1F), + cv.Optional(CONF_COMMAND): cv.hex_int_range(min=0, max=0x7F), } ) +# The legacy command is the 4 data bits followed by this many checksum bits +DRAYTON_NBITS_CHECKSUM_IN_COMMAND = 3 +# Either `data`, or the legacy `channel` + `command` pair (raw packet bits) +_validate_drayton_form = cv.All( + cv.has_none_or_all_keys(CONF_CHANNEL, CONF_COMMAND), + cv.has_exactly_one_key(CONF_DATA, CONF_COMMAND), +) -@register_binary_sensor("drayton", DraytonBinarySensor, DRAYTON_SCHEMA) +@register_binary_sensor( + "drayton", DraytonBinarySensor, cv.All(DRAYTON_SCHEMA, _validate_drayton_form) +) def drayton_binary_sensor(var, config): + # Matching compares address and data only + if (command := config.get(CONF_COMMAND)) is not None: + data = command >> DRAYTON_NBITS_CHECKSUM_IN_COMMAND + else: + data = config[CONF_DATA] cg.add( var.set_data( cg.StructInitializer( - DraytonData, - ("address", config[CONF_ADDRESS]), - ("channel", config[CONF_CHANNEL]), - ("command", config[CONF_COMMAND]), + DraytonData, ("address", config[CONF_ADDRESS]), ("data", data) ) ) ) @@ -1537,8 +1549,10 @@ register_protocol_action( "Drayton", DRAYTON_SCHEMA, (CONF_ADDRESS, "address", cg.uint16), + (CONF_DATA, "data", cg.uint8), (CONF_CHANNEL, "channel", cg.uint8), (CONF_COMMAND, "command", cg.uint8), + validate=_validate_drayton_form, ) diff --git a/esphome/components/remote_base/drayton_protocol.cpp b/esphome/components/remote_base/drayton_protocol.cpp index 598f1a476e..1bb2a68417 100644 --- a/esphome/components/remote_base/drayton_protocol.cpp +++ b/esphome/components/remote_base/drayton_protocol.cpp @@ -12,13 +12,12 @@ static constexpr uint8_t CARRIER_KHZ = 2; static constexpr uint8_t NBITS_PREAMBLE = 12; static constexpr uint8_t NBITS_SYNC = 4; static constexpr uint8_t NBITS_ADDRESS = 16; -static constexpr uint8_t NBITS_CHANNEL = 5; +static constexpr uint8_t NBITS_DATA = 4; +static constexpr uint8_t NBITS_CHECKSUM = 8; static constexpr uint8_t NBITS_COMMAND = 7; -static constexpr uint8_t NDATABITS = NBITS_ADDRESS + NBITS_CHANNEL + NBITS_COMMAND; -static constexpr uint8_t MIN_RX_SRC = (NDATABITS + NBITS_SYNC / 2); - -static constexpr uint8_t CMD_ON = 0x41; -static constexpr uint8_t CMD_OFF = 0x02; +static constexpr uint8_t NBITS_CHANNEL = 5; +static constexpr uint8_t NBITS_PKT = NBITS_ADDRESS + NBITS_DATA + NBITS_CHECKSUM; +static constexpr uint8_t MIN_RX_SRC = (NBITS_PKT + NBITS_SYNC / 2); /* Drayton Protocol @@ -31,59 +30,72 @@ The number of these bits seen at the receiver varies depending on the state of the bias when the packet transmission starts. The receiver algoritmn takes account of this. -The packet appears to be Manchester encoded, with a '10' tranmitted pair -representing a '1' bit and a '01' pair representing a '0' bit. Each packet is -begun with a '1100' syncronisation symbol which breaks this rule. Following +The packet is Manchester coded, with a '10' tranmitted pair representing +a '1' bit and a '01' pair representing a '0' bit. Each packet is begun +with a '1100' syncronisation symbol which breaks this rule. Following the sync are 28 '01' or '10' pairs. -------------------- -Boiler On Command as received: +Sample Boiler On Command as received: 101010101010110001101001010101101001010101010101100101010101101001011001 ppppppppppppSSSS-0-1-1-0-0-0-0-1-1-0-0-0-0-0-0-0-1-0-0-0-0-0-1-1-0-0-1-0 (Where pppp represents the preamble bits and SSSS represents the sync symbol) -28 bits of data received 01100001100000001000001 10010 (bin) or 6180832 (hex) +28 bits received 01100001100000001000001 10010 (bin) or 6180832 (hex) -Boiler Off Command as received: +Sample Boiler Off Command as received: 101010101010110001101001010101101001010101010101010101010110011001011001 ppppppppppppSSSS-0-1-1-0-0-0-0-1-1-0-0-0-0-0-0-0-0-0-0-0-0-1-0-1-0-0-1-0 -28 bits of data received 0110000110000000000001010010 (bin) or 6180052 (hex) +28 bits received 0110000110000000000001010010 (bin) or 6180052 (hex) -------------------- -I have used 'RFLink' software (RLink Firmware Version: 1.1 Revision: 48) to -capture and retransmit the Digistat packets. RFLink splits each packet into an -ID, SWITCH, and CMD field. +The 28 bits consist of 20 bits of payload and 8 bits of checksum. -0;17;Drayton;ID=c300;SWITCH=12;CMD=ON; -20;18;Drayton;ID=c300;SWITCH=12;CMD=OFF; +In order of transmission the payload consists of the following fields: --------------------- +Bits Description +12 ID of the thermostat. A Digistat receiver will only respond to a packet if its ID + matches the ID that the receiver has previously learnt. +4 Pairing field, normally 0000. The first packets sent by the thermostat after its + batteries are inserted have this field set to 0001, which lets a Digistat receiver + in pairing mode learn the ID of the thermostat. +1 Thermostat on (1) or off (0). +1 Always 0. +1 Low battery (1) or battery OK (0). +1 Always 0. -Spliting my received data into three parts of 16, 7 and 5 bits gives address, -channel and Command values of: +The YAML/lambda 'address' is the first 16 bits as sent (the 12 bit ID followed by the +pairing field, so the pairing flag is the address LSB), and 'data' is the next 4 bits +(0x8 = on, 0x2 = low battery). -On 6180832 0110000110000000 1000001 10010 -address: '0x6180' channel: '0x12' command: '0x41' +Checksum: see calc_cs(); the +1 when the sum is above 0x7F is empirical. The captured +packets above give 0x32 (On) and 0x52 (Off). Packets that fail it are logged at verbose level. -Off 6180052 0110000110000000 0000010 10010 -address: '0x6180' channel: '0x12' command: '0x02' - -These values are slightly different to those used by RFLink (the RFLink -ID/Adress value is rotated/manipulated), and I don't know who's interpretation -is correct. A larger data sample would help (I have only found five different -packet captures online) or definitive information from Drayton. - -Splitting each packet in this way works well for me with esphome. Any -corrections or additional data samples would be gratefully received. - -marshn +Legacy split (from RFLink): address (16), command (7: data + top 3 checksum bits), channel (5: low checksum bits). +When channel and command are set they are sent as is, with no checksum calculated. */ +static uint8_t calc_cs(uint32_t out_data) { + uint16_t cs = 0; + uint32_t rev = reverse_bits(out_data); + + for (uint8_t i = 4; i <= 20; i += 4) { + cs <<= 1; + cs += (rev >> i) & 0x0F; + } + + if (cs > 0x7F) { + cs += 1; + } + // Sent LSB first + return reverse_bits(uint8_t(cs << 1)); +} + void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { uint16_t khz = CARRIER_KHZ; dst->set_carrier_frequency(khz * 1000); @@ -108,17 +120,21 @@ void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { } } - ESP_LOGD(TAG, "Send Drayton: address=%04x channel=%03x cmd=%02x", data.address, data.channel, data.command); + if (data.channel || data.command) { + out_data = data.address; + out_data <<= NBITS_COMMAND; + out_data |= data.command; + out_data <<= NBITS_CHANNEL; + out_data |= data.channel; + } else { + out_data = data.address << (NBITS_DATA + NBITS_CHECKSUM); + out_data |= (data.data & ((1u << NBITS_DATA) - 1)) << NBITS_CHECKSUM; // a lambda may exceed 4 bits + out_data |= calc_cs(out_data); + } + ESP_LOGD(TAG, "addr=%04x data=%x channel=%02x command=%02x", data.address, data.data, data.channel, data.command); + ESP_LOGV(TAG, "out_data %07" PRIx32, out_data); - out_data = data.address; - out_data <<= NBITS_COMMAND; - out_data |= data.command; - out_data <<= NBITS_CHANNEL; - out_data |= data.channel; - - ESP_LOGV(TAG, "Send Drayton: out_data %08" PRIx32, out_data); - - for (uint32_t mask = 1UL << (NDATABITS - 1); mask != 0; mask >>= 1) { + for (uint32_t mask = 1UL << (NBITS_PKT - 1); mask != 0; mask >>= 1) { if (out_data & mask) { dst->mark(BIT_TIME_US); dst->space(BIT_TIME_US); @@ -130,21 +146,9 @@ void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { } optional DraytonProtocol::decode(RemoteReceiveData src) { - DraytonData out{ - .address = 0, - .channel = 0, - .command = 0, - }; + DraytonData out{}; while (src.size() - src.get_index() >= MIN_RX_SRC) { - ESP_LOGVV(TAG, - "Decode Drayton: %" PRId32 ", %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 - " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 - " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 "", - src.size() - src.get_index(), src.peek(0), src.peek(1), src.peek(2), src.peek(3), src.peek(4), - src.peek(5), src.peek(6), src.peek(7), src.peek(8), src.peek(9), src.peek(10), src.peek(11), src.peek(12), - src.peek(13), src.peek(14), src.peek(15), src.peek(16), src.peek(17), src.peek(18), src.peek(19)); - // If first preamble item is a space, skip it if (src.peek_space_at_least(1)) { src.advance(1); @@ -152,12 +156,12 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // Look for sync pulse, after. If sucessful index points to space of sync symbol while (src.size() - src.get_index() >= MIN_RX_SRC) { - ESP_LOGVV(TAG, "Decode Drayton: sync search %" PRIu32 ", %" PRId32 " %" PRId32, src.size() - src.get_index(), - src.peek(), src.peek(1)); + ESP_LOGVV(TAG, "sync search %" PRIu32 ", %" PRId32 " %" PRId32, src.size() - src.get_index(), src.peek(), + src.peek(1)); if (src.peek_mark(2 * BIT_TIME_US) && (src.peek_space(2 * BIT_TIME_US, 1) || src.peek_space(3 * BIT_TIME_US, 1))) { src.advance(1); - ESP_LOGVV(TAG, "Decode Drayton: Found SYNC, - %" PRIu32, src.get_index()); + ESP_LOGVV(TAG, "Found SYNC %" PRIu32, src.get_index()); break; } else { src.advance(2); @@ -165,8 +169,8 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { } // No point continuing if not enough samples remaining to complete a packet - if (src.size() - src.get_index() < NDATABITS) { - ESP_LOGV(TAG, "Decode Drayton: Fail 1, - %" PRIu32, src.get_index()); + if (src.size() - src.get_index() < NBITS_PKT) { + ESP_LOGV(TAG, "Fail1 %" PRIu32, src.get_index()); break; } @@ -174,17 +178,14 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // Extract first bit // Checks next bit to leave index pointing correctly uint32_t out_data = 0; - uint8_t bit = NDATABITS - 1; - ESP_LOGVV(TAG, "Decode Drayton: first bit %" PRId32 " %" PRId32 ", %" PRId32, src.peek(0), src.peek(1), - src.peek(2)); + uint8_t bit = NBITS_PKT - 1; if (src.expect_space(3 * BIT_TIME_US) && (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { out_data |= 0 << bit; } else if (src.expect_space(2 * BIT_TIME_US) && src.expect_mark(BIT_TIME_US) && (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) { out_data |= 1 << bit; } else { - ESP_LOGV(TAG, "Decode Drayton: Fail 2, - %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), - src.peek(1)); + ESP_LOGVV(TAG, "Fail2 %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), src.peek(1)); continue; } @@ -192,7 +193,7 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // if there is no transition at the start of the bit period, then the transition in the middle of // the previous bit period. while (--bit >= 1) { - ESP_LOGVV(TAG, "Decode Drayton: Data, %2d %08" PRIx32, bit, out_data); + ESP_LOGVV(TAG, "Data %2d %08" PRIx32, bit, out_data); if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { out_data |= 0 << bit; @@ -205,8 +206,7 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { } if (bit > 0) { - ESP_LOGVV(TAG, "Decode Drayton: Fail 3, %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), - src.peek(1)); + ESP_LOGVV(TAG, "Fail3 %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), src.peek(1)); continue; } @@ -218,21 +218,28 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { continue; } - ESP_LOGV(TAG, "Decode Drayton: Data, %2d %08" PRIx32, bit, out_data); + uint8_t cs = out_data & 0xFF; + uint8_t expected_cs = calc_cs(out_data); - out.channel = (uint8_t) (out_data & 0x1F); - out_data >>= NBITS_CHANNEL; - out.command = (uint8_t) (out_data & 0x7F); - out_data >>= NBITS_COMMAND; - out.address = (uint16_t) (out_data & 0xFFFF); + if (cs != expected_cs) { + ESP_LOGV(TAG, "Dropped %07" PRIx32 ": checksum 0x%02x, expected 0x%02x", out_data, cs, expected_cs); + continue; + } + + ESP_LOGV(TAG, "Data %07" PRIx32, out_data); + + out.data = (out_data >> NBITS_CHECKSUM) & ((1u << NBITS_DATA) - 1); + out.address = out_data >> (NBITS_CHECKSUM + NBITS_DATA); + out.channel = out_data & ((1u << NBITS_CHANNEL) - 1); + out.command = (out_data >> NBITS_CHANNEL) & ((1u << NBITS_COMMAND) - 1); return out; } return {}; } + void DraytonProtocol::dump(const DraytonData &data) { - ESP_LOGI(TAG, "Received Drayton: address=0x%04X (0x%04x), channel=0x%03x command=0x%03X", data.address, - ((data.address << 1) & 0xffff), data.channel, data.command); + ESP_LOGI(TAG, "Received Drayton: address=0x%04X, data=0x%X", data.address, data.data); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index ef9dc39fb3..83988fbaa6 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -11,10 +11,10 @@ struct DraytonData { uint16_t address; uint8_t channel; uint8_t command; + uint8_t data; // last, so positional initializers written for the legacy layout keep their meaning - bool operator==(const DraytonData &rhs) const { - return address == rhs.address && channel == rhs.channel && command == rhs.command; - } + // channel/command: legacy split of the same bits, filled on decode, ignored by operator== + bool operator==(const DraytonData &rhs) const { return address == rhs.address && data == rhs.data; } }; class DraytonProtocol : public RemoteProtocol { diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index cddee51195..36060d079c 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -17,6 +17,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "nec", "magiquest", "rc5x", + "drayton", "panasonic", ): request_protocol(protocol) diff --git a/tests/components/remote_base/drayton_test.cpp b/tests/components/remote_base/drayton_test.cpp new file mode 100644 index 0000000000..455235cd99 --- /dev/null +++ b/tests/components/remote_base/drayton_test.cpp @@ -0,0 +1,141 @@ +#include +#include +#include "esphome/components/remote_base/drayton_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr int32_t BIT_US = 500; +constexpr size_t PACKET_BITS = 28; + +// Captured packets from the header comment of drayton_protocol.cpp +constexpr uint32_t ON_PACKET = 0x6180832; +constexpr uint32_t OFF_PACKET = 0x6180052; +const char *const ON_CAPTURE = "101010101010110001101001010101101001010101010101100101010101101001011001"; +const char *const OFF_CAPTURE = "101010101010110001101001010101101001010101010101010101010110011001011001"; + +DraytonData with_data(uint16_t address, uint8_t data) { + DraytonData out{}; + out.address = address; + out.data = data; + return out; +} + +DraytonData with_legacy(uint16_t address, uint8_t channel, uint8_t command) { + DraytonData out{}; + out.address = address; + out.channel = channel; + out.command = command; + return out; +} + +RawTimings encode(const DraytonData &data) { + RemoteTransmitData tx; + DraytonProtocol().encode(&tx, data); + return tx.get_data(); +} + +// The packet bits sent after the preamble and sync, one Manchester pair per bit ('10' = 1) +uint32_t sent_packet(const RawTimings &timings) { + uint32_t word = 0; + for (size_t i = timings.size() - 2 * PACKET_BITS; i < timings.size(); i += 2) { + word = (word << 1) | (timings[i] > 0 ? 1 : 0); + } + return word; +} + +// A receiver reports runs of the same level as one item. +RawTimings as_received(const RawTimings &timings) { + RawTimings merged; + for (int32_t item : timings) { + if (!merged.empty() && (merged.back() < 0) == (item < 0)) { + merged.back() += item; + } else { + merged.push_back(item); + } + } + return merged; +} + +RawTimings from_capture(const std::string &bits) { + RawTimings timings; + for (char bit : bits) { + timings.push_back(bit == '1' ? BIT_US : -BIT_US); + } + return as_received(timings); +} + +optional decode(const RawTimings &timings) { + return DraytonProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +} // namespace + +TEST(DraytonProtocolTest, EncodeAppendsChecksum) { + EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x8))), ON_PACKET); + EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x0))), OFF_PACKET); + // A lambda value above 4 bits is masked instead of spilling into the address + EXPECT_EQ(sent_packet(encode(with_data(0x6180, 0x18))), ON_PACKET); +} + +TEST(DraytonProtocolTest, LegacyEncodeSendsChannelAndCommandAsIs) { + EXPECT_EQ(sent_packet(encode(with_legacy(0x6180, 0x12, 0x41))), ON_PACKET); + EXPECT_EQ(sent_packet(encode(with_legacy(0x1234, 0x01, 0x05))), 0x12340A1u); +} + +TEST(DraytonProtocolTest, DecodesCapturedPackets) { + auto on = decode(from_capture(ON_CAPTURE)); + ASSERT_TRUE(on.has_value()); + if (!on.has_value()) { + return; + } + EXPECT_EQ(on->address, 0x6180); + EXPECT_EQ(on->data, 0x8); + EXPECT_EQ(on->channel, 0x12); + EXPECT_EQ(on->command, 0x41); + + auto off = decode(from_capture(OFF_CAPTURE)); + ASSERT_TRUE(off.has_value()); + if (!off.has_value()) { + return; + } + EXPECT_EQ(off->address, 0x6180); + EXPECT_EQ(off->data, 0x0); + EXPECT_EQ(off->channel, 0x12); + EXPECT_EQ(off->command, 0x02); +} + +TEST(DraytonProtocolTest, RoundTrips) { + for (uint16_t address : {0x0000, 0x6180, 0x1231, 0xFFFF}) { + for (uint8_t data : {0x0, 0x2, 0x8, 0xA, 0xF}) { + auto decoded = decode(as_received(encode(with_data(address, data)))); + ASSERT_TRUE(decoded.has_value()) << std::hex << address << " " << int(data); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, address); + EXPECT_EQ(decoded->data, data); + } + } +} + +TEST(DraytonProtocolTest, RejectsBadChecksum) { + // On packet with the low checksum bits changed from 0x12 to 0x13 + EXPECT_FALSE(decode(as_received(encode(with_legacy(0x6180, 0x13, 0x41)))).has_value()); + // On packet with the data bits of the Off packet + EXPECT_FALSE(decode(as_received(encode(with_legacy(0x6180, 0x12, 0x01)))).has_value()); +} + +TEST(DraytonProtocolTest, BinarySensorFormsMatchDecodedPacket) { + auto on = decode(from_capture(ON_CAPTURE)); + ASSERT_TRUE(on.has_value()); + if (!on.has_value()) { + return; + } + // The decoded channel and command are ignored + EXPECT_TRUE(*on == with_data(0x6180, 0x8)); + EXPECT_FALSE(*on == with_data(0x6181, 0x8)); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index fca5b0c374..cd01690344 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -51,8 +51,8 @@ on_dooya: on_drayton: then: - logger.log: - format: "on_drayton: %u %u %u" - args: ["x.address", "x.channel", "x.command"] + format: "on_drayton: %u %u %u %u" + args: ["x.address", "x.channel", "x.command", "x.data"] on_dyson: then: - logger.log: diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index f66c2ed3b0..d2b135f034 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -21,6 +21,17 @@ binary_sensor: address: 0x00 command: 0x0D extension: 0x03 + - platform: remote_receiver + name: Drayton Thermostat On + drayton: + address: 0x6180 + data: 0x8 + - platform: remote_receiver + name: Drayton Thermostat On Legacy + drayton: + address: 0x6180 + channel: 0x12 + command: 0x41 - platform: remote_receiver name: Panasonic 56 bit Remote Input panasonic: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 2ef158456e..52a43f41b9 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -27,6 +27,13 @@ button: on_press: remote_transmitter.transmit_brennenstuhl: code: 0xBD2E2C + - platform: template + name: Drayton Thermostat On + id: drayton_thermostat_on + on_press: + remote_transmitter.transmit_drayton: + address: 0x1230 + data: 0x8 - platform: template name: Dyson fan up id: dyson_fan_up From 1e05106a783084815d2be10c79373becc5e7a2d9 Mon Sep 17 00:00:00 2001 From: mfishma Date: Mon, 12 Oct 2026 01:01:48 -0700 Subject: [PATCH 171/268] [mipi_spi] Add M5STACK-ATOMS3R-ST7735 as a display panel configuration (#18673) --- esphome/components/mipi_spi/models/ili.py | 2 +- esphome/components/mipi_spi/models/m5stack.py | 23 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 187fcfd8c0..835799c262 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -649,7 +649,7 @@ DriverChip( (0xF3, 0x52, 0xA4, 0x7F, 0x33, 0x34, 0xDF), ), ) -DriverChip( +ST7735 = DriverChip( "ST7735", color_order=MODE_RGB, width=128, diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index a54bd19d88..f62bee5f60 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -4,6 +4,7 @@ from esphome.components.mipi import ( GMCTRP1, IDMOFF, IFMODE, + MODE_BGR, PWCTR1, PWCTR2, SETEXTC, @@ -11,7 +12,7 @@ from esphome.components.mipi import ( DriverChip, ) -from .ili import ILI9341, ST7789V +from .ili import ILI9341, ST7735, ST7789V # fmt: off DriverChip( @@ -71,3 +72,23 @@ GC9107.extend( cs_pin=14, requires={"psram"}, ) + +# M5Stack AtomS3R (ST7735 revision - May 14, 2026+) +ST7735.extend( + "M5STACK-ATOMS3R-ST7735", + native_width=132, + native_height=132, + width=128, + height=128, + offset_width=2, + offset_height=1, + pad_width=2, + pad_height=3, + data_rate="40MHz", + color_order=MODE_BGR, + invert_colors=True, + reset_pin=48, + dc_pin=42, + cs_pin=14, + requires={"psram"}, +) From 602bf00435e89883b208dc66028b883b13b232da Mon Sep 17 00:00:00 2001 From: Anil Daoud Date: Mon, 12 Oct 2026 16:21:26 +0800 Subject: [PATCH 172/268] [daikin_brc] Add sub_unit parameter for multi-unit addressing (#15234) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/daikin_brc/climate.py | 4 ++++ esphome/components/daikin_brc/daikin_brc.cpp | 11 ++++++++--- esphome/components/daikin_brc/daikin_brc.h | 3 +++ tests/components/daikin_brc/common.yaml | 4 ++++ tests/components/daikin_brc/test.esp32-idf.yaml | 3 +-- tests/components/daikin_brc/test.esp8266-ard.yaml | 3 +-- 6 files changed, 21 insertions(+), 7 deletions(-) diff --git a/esphome/components/daikin_brc/climate.py b/esphome/components/daikin_brc/climate.py index c5c1d3739e..c710dc94a8 100644 --- a/esphome/components/daikin_brc/climate.py +++ b/esphome/components/daikin_brc/climate.py @@ -6,6 +6,8 @@ from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] +CONF_SUB_UNIT = "sub_unit" + daikin_brc_ns = cg.esphome_ns.namespace("daikin_brc") DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR) @@ -13,6 +15,7 @@ DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).extend( { cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, + cv.Optional(CONF_SUB_UNIT, default=0): cv.int_range(min=0, max=5), } ) @@ -20,3 +23,4 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).ext async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) + cg.add(var.set_sub_unit(config[CONF_SUB_UNIT])) diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index cfb3521a66..c16eea733f 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -20,13 +20,18 @@ void DaikinBrcClimate::transmit_state() { 0xDA, 0x17, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00}; + // The low 3 bits of the frame header byte address the indoor unit, in the preamble and the state frame + remote_state[3] |= this->sub_unit_; + remote_state[10] |= this->sub_unit_; + remote_state[6] += this->sub_unit_; // preamble checksum covers byte 3 + remote_state[12] = this->alt_mode_(); remote_state[13] = this->mode_button_; remote_state[14] = this->operation_mode_(); remote_state[17] = this->temperature_(); remote_state[18] = this->fan_speed_swing_(); - // Calculate checksum + // Calculate state frame checksum for (int i = DAIKIN_BRC_PREAMBLE_SIZE; i < DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1; i++) { remote_state[DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1] += remote_state[i]; } @@ -254,8 +259,8 @@ bool DaikinBrcClimate::on_receive(remote_base::RemoteReceiveData data) { if (byte != 0x17) return false; } else if (pos == 3) { - // frame header - if (byte != 0x18) + // frame header, with this unit's address in the low 3 bits + if (byte != (0x18 | this->sub_unit_)) return false; } else if (pos == 4) { // frame type diff --git a/esphome/components/daikin_brc/daikin_brc.h b/esphome/components/daikin_brc/daikin_brc.h index c963f72ab6..5160e44a11 100644 --- a/esphome/components/daikin_brc/daikin_brc.h +++ b/esphome/components/daikin_brc/daikin_brc.h @@ -60,6 +60,8 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR { this->fahrenheit_ = value; this->temperature_step_ = value ? 0.5f : 1.0f; } + /// Which indoor unit (0 to 5) this climate addresses on a multi-unit system + void set_sub_unit(uint8_t value) { this->sub_unit_ = value; } protected: uint8_t mode_button_ = 0x00; @@ -75,6 +77,7 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR { bool on_receive(remote_base::RemoteReceiveData data) override; bool parse_state_frame_(const uint8_t frame[]); bool fahrenheit_{false}; + uint8_t sub_unit_{0}; }; } // namespace esphome::daikin_brc diff --git a/tests/components/daikin_brc/common.yaml b/tests/components/daikin_brc/common.yaml index 89786954ba..0e363cd156 100644 --- a/tests/components/daikin_brc/common.yaml +++ b/tests/components/daikin_brc/common.yaml @@ -2,3 +2,7 @@ climate: - platform: daikin_brc name: Daikin_brc Climate transmitter_id: xmitr + - platform: daikin_brc + name: Daikin_brc Climate Unit 2 + transmitter_id: xmitr + sub_unit: 1 diff --git a/tests/components/daikin_brc/test.esp32-idf.yaml b/tests/components/daikin_brc/test.esp32-idf.yaml index e891f9dc85..9761ee9ede 100644 --- a/tests/components/daikin_brc/test.esp32-idf.yaml +++ b/tests/components/daikin_brc/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml - -<<: !include common.yaml + daikin_brc: !include common.yaml diff --git a/tests/components/daikin_brc/test.esp8266-ard.yaml b/tests/components/daikin_brc/test.esp8266-ard.yaml index 4bed2f03e5..6140ccb881 100644 --- a/tests/components/daikin_brc/test.esp8266-ard.yaml +++ b/tests/components/daikin_brc/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml - -<<: !include common.yaml + daikin_brc: !include common.yaml From e92d23999635b136f1775fe75e131089d665ba2c Mon Sep 17 00:00:00 2001 From: "Sebastian G." Date: Mon, 12 Oct 2026 10:37:05 +0200 Subject: [PATCH 173/268] [dsmr] add Switzerland channel 1-1 sensors (EWL Luzern AM550) (#17391) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/dsmr/__init__.py | 2 +- esphome/components/dsmr/sensor.py | 168 ++++++++++++++++++++++ platformio.ini | 2 +- tests/components/dsmr/test.esp32-idf.yaml | 60 ++++++++ 4 files changed, 230 insertions(+), 2 deletions(-) diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 07dbf55831..4ba9d1696a 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -87,7 +87,7 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - cg.add_library("esphome/dsmr_parser", "1.9.0") + cg.add_library("esphome/dsmr_parser", "1.10.0") def final_validate(config: ConfigType) -> None: diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index 6aecc62d6b..c8d12c326b 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -97,6 +97,12 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional("energy_delivered_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("energy_delivered_tariff1_ch"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=3, @@ -109,6 +115,12 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional("energy_returned_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("energy_returned_tariff1_ch"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=3, @@ -121,6 +133,77 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + # Reactive energy totals and per-quadrant (Q1-Q4) values (Switzerland, e.g. EWL Luzern AM550) + cv.Optional("total_imported_energy_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("total_exported_energy_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q1_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q1_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q2_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q2_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q3_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q3_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q4_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q4_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q4_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("energy_delivered_tariff1_il"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=3, @@ -231,6 +314,91 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + # Per-phase active power and maximum demand (Switzerland, e.g. EWL Luzern AM550) + cv.Optional("power_delivered_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_delivered_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_delivered_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_returned_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_returned_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_returned_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_delivered_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_delivered_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_delivered_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_returned_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_returned_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_returned_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("maximum_power_delivered_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("maximum_power_returned_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), cv.Optional("reactive_power_delivered"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, accuracy_decimals=3, diff --git a/platformio.ini b/platformio.ini index 7b3414a32a..0d6338d6cd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,7 +37,7 @@ lib_deps_base = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - esphome/dsmr_parser@1.9.0 ; dsmr + esphome/dsmr_parser@1.10.0 ; dsmr https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library diff --git a/tests/components/dsmr/test.esp32-idf.yaml b/tests/components/dsmr/test.esp32-idf.yaml index 9eb7d3e178..4e8224226f 100644 --- a/tests/components/dsmr/test.esp32-idf.yaml +++ b/tests/components/dsmr/test.esp32-idf.yaml @@ -12,6 +12,66 @@ sensor: name: "Energy Consumed Luxembourg. OBIS: 1-0:1.8.0" energy_delivered_tariff1: name: "Energy Consumed Tariff 1. OBIS: 1-0:1.8.1" + energy_delivered_ch: + name: "Energy Consumed (CH). OBIS: 1-1:1.8.0" + energy_returned_ch: + name: "Energy Produced (CH). OBIS: 1-1:2.8.0" + total_imported_energy_ch: + name: "Reactive Energy Imported (CH). OBIS: 1-1:3.8.0" + total_exported_energy_ch: + name: "Reactive Energy Exported (CH). OBIS: 1-1:4.8.0" + reactive_energy_q1_ch: + name: "Reactive Energy Q1 (CH). OBIS: 1-1:5.8.0" + reactive_energy_q1_tariff1_ch: + name: "Reactive Energy Q1 Tariff 1 (CH). OBIS: 1-1:5.8.1" + reactive_energy_q1_tariff2_ch: + name: "Reactive Energy Q1 Tariff 2 (CH). OBIS: 1-1:5.8.2" + reactive_energy_q2_ch: + name: "Reactive Energy Q2 (CH). OBIS: 1-1:6.8.0" + reactive_energy_q2_tariff1_ch: + name: "Reactive Energy Q2 Tariff 1 (CH). OBIS: 1-1:6.8.1" + reactive_energy_q2_tariff2_ch: + name: "Reactive Energy Q2 Tariff 2 (CH). OBIS: 1-1:6.8.2" + reactive_energy_q3_ch: + name: "Reactive Energy Q3 (CH). OBIS: 1-1:7.8.0" + reactive_energy_q3_tariff1_ch: + name: "Reactive Energy Q3 Tariff 1 (CH). OBIS: 1-1:7.8.1" + reactive_energy_q3_tariff2_ch: + name: "Reactive Energy Q3 Tariff 2 (CH). OBIS: 1-1:7.8.2" + reactive_energy_q4_ch: + name: "Reactive Energy Q4 (CH). OBIS: 1-1:8.8.0" + reactive_energy_q4_tariff1_ch: + name: "Reactive Energy Q4 Tariff 1 (CH). OBIS: 1-1:8.8.1" + reactive_energy_q4_tariff2_ch: + name: "Reactive Energy Q4 Tariff 2 (CH). OBIS: 1-1:8.8.2" + power_delivered_l1_ch: + name: "Power Consumed L1 (CH). OBIS: 1-1:21.7.0" + power_delivered_l2_ch: + name: "Power Consumed L2 (CH). OBIS: 1-1:41.7.0" + power_delivered_l3_ch: + name: "Power Consumed L3 (CH). OBIS: 1-1:61.7.0" + power_returned_l1_ch: + name: "Power Produced L1 (CH). OBIS: 1-1:22.7.0" + power_returned_l2_ch: + name: "Power Produced L2 (CH). OBIS: 1-1:42.7.0" + power_returned_l3_ch: + name: "Power Produced L3 (CH). OBIS: 1-1:62.7.0" + reactive_power_delivered_l1_ch: + name: "Reactive Power Consumed L1 (CH). OBIS: 1-1:23.7.0" + reactive_power_delivered_l2_ch: + name: "Reactive Power Consumed L2 (CH). OBIS: 1-1:43.7.0" + reactive_power_delivered_l3_ch: + name: "Reactive Power Consumed L3 (CH). OBIS: 1-1:63.7.0" + reactive_power_returned_l1_ch: + name: "Reactive Power Produced L1 (CH). OBIS: 1-1:24.7.0" + reactive_power_returned_l2_ch: + name: "Reactive Power Produced L2 (CH). OBIS: 1-1:44.7.0" + reactive_power_returned_l3_ch: + name: "Reactive Power Produced L3 (CH). OBIS: 1-1:64.7.0" + maximum_power_delivered_ch: + name: "Maximum Power Consumed (CH). OBIS: 1-1:1.6.0" + maximum_power_returned_ch: + name: "Maximum Power Produced (CH). OBIS: 1-1:2.6.0" text_sensor: - platform: dsmr From af76f19ab48834ea7d87e35dbf2cfb6d3afda6b4 Mon Sep 17 00:00:00 2001 From: BugHaver <43462320+bughaver@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:18:22 +1100 Subject: [PATCH 174/268] [heatpumpir] Add ZMP IR receiver (#17099) 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/heatpumpir/heatpumpir.cpp | 19 +++ esphome/components/heatpumpir/heatpumpir.h | 11 +- .../heatpumpir/receiver_mitsubishi_heavy.cpp | 109 ++++++++++++++++++ .../heatpumpir/receiver_mitsubishi_heavy.h | 20 ++++ .../receiver_mitsubishi_heavy_zmp.cpp | 63 ++++++++++ tests/components/heatpumpir/common.yaml | 9 ++ .../heatpumpir/test.bk72xx-ard.yaml | 4 +- .../components/heatpumpir/test.esp32-ard.yaml | 4 +- .../components/heatpumpir/test.esp32-idf.yaml | 4 +- .../heatpumpir/test.esp8266-ard.yaml | 4 +- .../common/remote_receiver/bk72xx-ard.yaml | 12 ++ 11 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp create mode 100644 esphome/components/heatpumpir/receiver_mitsubishi_heavy.h create mode 100644 esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp create mode 100644 tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 2376a67416..ca0ffb37c1 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -1,5 +1,7 @@ #include "heatpumpir.h" +#include "receiver_mitsubishi_heavy.h" + #if defined(USE_ARDUINO) || defined(USE_ESP32) #include @@ -246,6 +248,23 @@ void HeatpumpIRClimate::transmit_state() { swing_h_cmd); } +bool HeatpumpIRClimate::on_receive(remote_base::RemoteReceiveData data) { + switch (this->protocol_) { + case PROTOCOL_MITSUBISHI_HEAVY_ZMP: { + uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]; + if (!decode_mitsubishi_heavy_frame(data, frame, *this)) + return false; + decode_mitsubishi_heavy_zmp(frame, *this); + break; + } + default: + return false; // no decoder for this protocol + } + this->target_temperature = clamp(this->target_temperature, this->min_temperature_, this->max_temperature_); + this->publish_state(); + return true; +} + } // namespace esphome::heatpumpir #endif diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index 8e0668d59d..2c119c7550 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -102,7 +102,14 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} void setup() override; - void set_protocol(Protocol protocol) { this->protocol_ = protocol; } + void set_protocol(Protocol protocol) { + this->protocol_ = protocol; + if (protocol == PROTOCOL_MITSUBISHI_HEAVY_ZMP) { + this->presets_.insert(climate::CLIMATE_PRESET_NONE); + this->presets_.insert(climate::CLIMATE_PRESET_ECO); + this->presets_.insert(climate::CLIMATE_PRESET_BOOST); + } + } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; } @@ -117,6 +124,8 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { HeatpumpIR *heatpump_ir_; /// Transmit via IR the state of this climate controller. void transmit_state() override; + /// Handle received IR data from the remote control. + bool on_receive(remote_base::RemoteReceiveData data) override; Protocol protocol_; HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp new file mode 100644 index 0000000000..004eb46c00 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp @@ -0,0 +1,109 @@ +#include "receiver_mitsubishi_heavy.h" +#include "heatpumpir.h" + +#if defined(USE_ARDUINO) || defined(USE_ESP32) + +#include + +namespace esphome::heatpumpir { + +static constexpr uint8_t FRAME_PREFIX[5] = {0x52, 0xAE, 0xC3, 0x26, 0xD9}; +static constexpr uint8_t XOR_CHECKSUM_EXPECTED = 0xFF; +static constexpr uint8_t PREFIX_LENGTH = 5; +static constexpr uint8_t MODE_TEMP_BYTE = 9; +static constexpr uint8_t MODE_MASK = 0x07; +static constexpr uint8_t TEMP_MASK = 0x0F; +static constexpr uint8_t TEMPERATURE_BASE = 17; +static constexpr uint8_t TEMPERATURE_SHIFT = 4; + +static bool read_raw_bytes(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + if (!data.expect_item(MITSUBISHI_HEAVY_HDR_MARK, MITSUBISHI_HEAVY_HDR_SPACE)) + return false; + + for (uint8_t i = 0; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i++) { + uint8_t byte = 0; + for (uint8_t bit = 0; bit < 8; bit++) { + if (data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ONE_SPACE)) { + byte |= 1 << bit; + } else if (!data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ZERO_SPACE)) { + return false; + } + } + frame[i] = byte; + } + + return true; +} + +static bool validate_prefix(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + for (uint8_t i = 0; i < PREFIX_LENGTH; i++) { + if (frame[i] != FRAME_PREFIX[i]) + return false; + } + return true; +} + +static bool validate_checksums(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + for (uint8_t i = PREFIX_LENGTH; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i += 2) { + if ((uint8_t) (frame[i] ^ frame[i + 1]) != XOR_CHECKSUM_EXPECTED) + return false; + } + return true; +} + +static bool decode_mode_field(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], climate::ClimateMode &mode) { + if (frame[MODE_TEMP_BYTE] & MITSUBISHI_HEAVY_MODE_OFF) { + mode = climate::CLIMATE_MODE_OFF; + return true; + } + switch (frame[MODE_TEMP_BYTE] & MODE_MASK) { + case MITSUBISHI_HEAVY_MODE_AUTO: + mode = climate::CLIMATE_MODE_HEAT_COOL; + return true; + case MITSUBISHI_HEAVY_MODE_HEAT: + mode = climate::CLIMATE_MODE_HEAT; + return true; + case MITSUBISHI_HEAVY_MODE_COOL: + mode = climate::CLIMATE_MODE_COOL; + return true; + case MITSUBISHI_HEAVY_MODE_DRY: + mode = climate::CLIMATE_MODE_DRY; + return true; + case MITSUBISHI_HEAVY_MODE_FAN: + mode = climate::CLIMATE_MODE_FAN_ONLY; + return true; + default: + return false; + } +} + +static float decode_temperature_field(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + uint8_t temp_nibble = frame[MODE_TEMP_BYTE] >> TEMPERATURE_SHIFT; + uint8_t inverted_nibble = ~temp_nibble; + uint8_t temp_offset = inverted_nibble & TEMP_MASK; + return TEMPERATURE_BASE + temp_offset; +} + +static bool apply_climate_state(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate) { + climate::ClimateMode mode; + if (!decode_mode_field(frame, mode)) + return false; + climate.mode = mode; + climate.target_temperature = decode_temperature_field(frame); + return true; +} + +bool decode_mitsubishi_heavy_frame(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], + HeatpumpIRClimate &climate) { + if (!read_raw_bytes(data, frame)) + return false; + if (!validate_prefix(frame)) + return false; + if (!validate_checksums(frame)) + return false; + return apply_climate_state(frame, climate); +} + +} // namespace esphome::heatpumpir + +#endif diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h new file mode 100644 index 0000000000..61e3c34ca4 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::heatpumpir { + +class HeatpumpIRClimate; + +/// Bytes in a Mitsubishi Heavy IR frame, prefix included +inline constexpr uint8_t MITSUBISHI_HEAVY_FRAME_LENGTH = 11; + +/// Read a Mitsubishi Heavy frame into `frame`, check its prefix and inverted byte pairs, and apply the +/// fields every variant shares (mode and target temperature). Returns false when no valid frame was read. +bool decode_mitsubishi_heavy_frame(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], + HeatpumpIRClimate &climate); + +/// Apply the ZMP-specific fields (fan, preset, swing) of a validated frame. +void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate); + +} // namespace esphome::heatpumpir diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp b/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp new file mode 100644 index 0000000000..4d92c35c77 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp @@ -0,0 +1,63 @@ +#include "receiver_mitsubishi_heavy.h" +#include "heatpumpir.h" + +#if defined(USE_ARDUINO) || defined(USE_ESP32) + +#include + +namespace esphome::heatpumpir { + +// Fan speed sits in bits 7:5 of byte 7; the vertical swing code spans bit 1 of byte 5 and bits 4:3 of byte 7 +static constexpr uint8_t BYTE_SWING_H = 5; +static constexpr uint8_t BYTE_FAN = 7; +static constexpr uint8_t FAN_SPEED_MASK = 0xE0; +static constexpr uint8_t VS_MASK_BYTE5 = 0x02; +static constexpr uint8_t VS_MASK_BYTE7 = 0x18; +// The library has no horizontal swing for ZMP and always sends MITSUBISHI_HEAVY_ZMP_HS_STOP (0xCC) in byte 5. +// Captures of the remote show 0x5C while it swings; the mask leaves out the vertical swing and clean bits. +static constexpr uint8_t HORIZONTAL_SWING_MASK = 0xDC; +static constexpr uint8_t HORIZONTAL_SWING_ACTIVE = 0x5C; + +void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate) { + uint8_t fan = frame[BYTE_FAN] & FAN_SPEED_MASK; + climate.preset = climate::CLIMATE_PRESET_NONE; + switch (fan) { + case MITSUBISHI_HEAVY_ZMP_FAN_AUTO: + climate.fan_mode = climate::CLIMATE_FAN_AUTO; + break; + case MITSUBISHI_HEAVY_ZMP_FAN1: + climate.fan_mode = climate::CLIMATE_FAN_LOW; + break; + case MITSUBISHI_HEAVY_ZMP_FAN2: + climate.fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case MITSUBISHI_HEAVY_ZMP_FAN3: + climate.fan_mode = climate::CLIMATE_FAN_HIGH; + break; + case MITSUBISHI_HEAVY_ZMP_HIPOWER: + climate.preset = climate::CLIMATE_PRESET_BOOST; + break; // the remote keeps the fan mode and restores it when the preset ends + case MITSUBISHI_HEAVY_ZMP_ECONO: + climate.preset = climate::CLIMATE_PRESET_ECO; + break; + } + + uint8_t swing_h = frame[BYTE_SWING_H] & HORIZONTAL_SWING_MASK; + uint8_t swing_v = (frame[BYTE_SWING_H] & VS_MASK_BYTE5) | (frame[BYTE_FAN] & VS_MASK_BYTE7); + bool h_swing = (swing_h == HORIZONTAL_SWING_ACTIVE); + bool v_swing = (swing_v == MITSUBISHI_HEAVY_ZMP_VS_SWING); + + if (h_swing && v_swing) { + climate.swing_mode = climate::CLIMATE_SWING_BOTH; + } else if (h_swing) { + climate.swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + } else if (v_swing) { + climate.swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else { + climate.swing_mode = climate::CLIMATE_SWING_OFF; + } +} + +} // namespace esphome::heatpumpir + +#endif diff --git a/tests/components/heatpumpir/common.yaml b/tests/components/heatpumpir/common.yaml index a2779f9803..24cc22f06f 100644 --- a/tests/components/heatpumpir/common.yaml +++ b/tests/components/heatpumpir/common.yaml @@ -7,6 +7,15 @@ climate: min_temperature: 18 max_temperature: 30 transmitter_id: xmitr + - platform: heatpumpir + protocol: mitsubishi_heavy_zmp + horizontal_default: left + vertical_default: up + name: HeatpumpIR Climate Mitsubishi ZMP + min_temperature: 18 + max_temperature: 30 + transmitter_id: xmitr + receiver_id: rcvr - platform: heatpumpir protocol: daikin horizontal_default: mleft diff --git a/tests/components/heatpumpir/test.bk72xx-ard.yaml b/tests/components/heatpumpir/test.bk72xx-ard.yaml index 6cce191825..779adcc307 100644 --- a/tests/components/heatpumpir/test.bk72xx-ard.yaml +++ b/tests/components/heatpumpir/test.bk72xx-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/bk72xx-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/bk72xx-ard.yaml + heatpumpir: !include common.yaml diff --git a/tests/components/heatpumpir/test.esp32-ard.yaml b/tests/components/heatpumpir/test.esp32-ard.yaml index 01009de071..bd5a74f356 100644 --- a/tests/components/heatpumpir/test.esp32-ard.yaml +++ b/tests/components/heatpumpir/test.esp32-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-ard.yaml + heatpumpir: !include common.yaml diff --git a/tests/components/heatpumpir/test.esp32-idf.yaml b/tests/components/heatpumpir/test.esp32-idf.yaml index e891f9dc85..3c81acdbab 100644 --- a/tests/components/heatpumpir/test.esp32-idf.yaml +++ b/tests/components/heatpumpir/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + heatpumpir: !include common.yaml diff --git a/tests/components/heatpumpir/test.esp8266-ard.yaml b/tests/components/heatpumpir/test.esp8266-ard.yaml index 4bed2f03e5..5449d62be4 100644 --- a/tests/components/heatpumpir/test.esp8266-ard.yaml +++ b/tests/components/heatpumpir/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 + heatpumpir: !include common.yaml diff --git a/tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml b/tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml new file mode 100644 index 0000000000..d73aca27d7 --- /dev/null +++ b/tests/test_build_components/common/remote_receiver/bk72xx-ard.yaml @@ -0,0 +1,12 @@ +# Common remote_receiver configuration for BK72XX Arduino tests +# Provides a shared remote receiver that all components can use +# Components will auto-use this receiver if they don't specify receiver_id + +substitutions: + remote_receiver_pin: GPIO17 + +remote_receiver: + id: rcvr + pin: ${remote_receiver_pin} + dump: all + tolerance: 25% From 5a90bceba0ff9d9f7c591083948300068f292788 Mon Sep 17 00:00:00 2001 From: "Joshua M. Boniface" Date: Mon, 12 Oct 2026 05:42:17 -0400 Subject: [PATCH 175/268] [cse7766] Clamp a power factor above 1 instead of publishing NaN (#15307) 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/cse7766/cse7766.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 0bf077b746..bbe76855fb 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -202,9 +202,10 @@ void CSE7766Component::parse_data_() { float pf = NAN; if (apparent_power > 0) { pf = power / apparent_power; - if (pf < 0 || pf > 1) { - ESP_LOGD(TAG, "Impossible power factor: %.4f not in interval [0, 1]", pf); - pf = NAN; + if (pf > 1) { + // Noise near the chip's resolution limit, seen during inrush, pushes the ratio past unity + ESP_LOGD(TAG, "Power factor %.4f above 1, clamped", pf); + pf = 1.0f; } } else if (apparent_power == 0 && power == 0) { // No load, report ideal power factor From 3472ac3dcc06f37b398c5b72109f1f38ba5ec021 Mon Sep 17 00:00:00 2001 From: BugHaver <43462320+bughaver@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:44:01 +1100 Subject: [PATCH 176/268] [heatpumpir] Add Eco and Boost presets and fix fan speeds on Mitsubishi Heavy ZJ and ZMP (#16877) Co-authored-by: J. Nick Koston --- esphome/components/heatpumpir/heatpumpir.cpp | 25 ++++++++++++++++++-- esphome/components/heatpumpir/heatpumpir.h | 11 +++++---- tests/components/heatpumpir/common.yaml | 8 +++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index ca0ffb37c1..b4597fe4ab 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -127,6 +127,13 @@ void HeatpumpIRClimate::setup() { } } +void HeatpumpIRClimate::control(const climate::ClimateCall &call) { + // Picking a fan speed ends HiPower or Econo, as it does on the remote + if (this->has_fan_byte_presets_() && call.get_fan_mode().has_value() && !call.get_preset().has_value()) + this->preset = climate::CLIMATE_PRESET_NONE; + climate_ir::ClimateIR::control(call); +} + void HeatpumpIRClimate::transmit_state() { uint8_t power_mode_cmd; uint8_t operating_mode_cmd; @@ -190,8 +197,9 @@ void HeatpumpIRClimate::transmit_state() { } // MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto, - // so low/medium/high have to start at FAN_1 there instead of FAN_2. - const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC; + // so low/medium/high have to start at FAN_1 there instead of FAN_2. Mitsubishi Heavy + // ZJ and ZMP also stop at FAN_3 and use FAN_4 and FAN_5 for HiPower and Econo. + const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC || this->has_fan_byte_presets_(); switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { case climate::CLIMATE_FAN_LOW: fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2; @@ -208,6 +216,19 @@ void HeatpumpIRClimate::transmit_state() { break; } + if (this->has_fan_byte_presets_()) { + switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { + case climate::CLIMATE_PRESET_BOOST: + fan_speed_cmd = FAN_4; // HiPower + break; + case climate::CLIMATE_PRESET_ECO: + fan_speed_cmd = FAN_5; // Econo + break; + default: + break; + } + } + switch (this->mode) { case climate::CLIMATE_MODE_COOL: power_mode_cmd = POWER_ON; diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index 2c119c7550..d00fa91d05 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -104,10 +104,8 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { void setup() override; void set_protocol(Protocol protocol) { this->protocol_ = protocol; - if (protocol == PROTOCOL_MITSUBISHI_HEAVY_ZMP) { - this->presets_.insert(climate::CLIMATE_PRESET_NONE); - this->presets_.insert(climate::CLIMATE_PRESET_ECO); - this->presets_.insert(climate::CLIMATE_PRESET_BOOST); + if (this->has_fan_byte_presets_()) { + this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; } } void set_horizontal_default(HorizontalDirection horizontal_direction) { @@ -122,8 +120,13 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { protected: HeatpumpIR *heatpump_ir_; + void control(const climate::ClimateCall &call) override; /// Transmit via IR the state of this climate controller. void transmit_state() override; + /// Mitsubishi Heavy ZJ and ZMP carry HiPower and Econo in the fan byte as FAN_4 and FAN_5 + bool has_fan_byte_presets_() const { + return this->protocol_ == PROTOCOL_MITSUBISHI_HEAVY_ZJ || this->protocol_ == PROTOCOL_MITSUBISHI_HEAVY_ZMP; + } /// Handle received IR data from the remote control. bool on_receive(remote_base::RemoteReceiveData data) override; Protocol protocol_; diff --git a/tests/components/heatpumpir/common.yaml b/tests/components/heatpumpir/common.yaml index 24cc22f06f..de45f91ec9 100644 --- a/tests/components/heatpumpir/common.yaml +++ b/tests/components/heatpumpir/common.yaml @@ -7,6 +7,14 @@ climate: min_temperature: 18 max_temperature: 30 transmitter_id: xmitr + - platform: heatpumpir + protocol: mitsubishi_heavy_zj + horizontal_default: left + vertical_default: mup + name: HeatpumpIR Climate Mitsubishi ZJ + min_temperature: 18 + max_temperature: 30 + transmitter_id: xmitr - platform: heatpumpir protocol: mitsubishi_heavy_zmp horizontal_default: left From c0d4287c6e2e754af8e266db544fc949a3252257 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:28:07 -1000 Subject: [PATCH 177/268] [core] Create the PlatformIO HTTP cache dir before the prefetch resolve threads run (#20579) --- esphome/platformio/library.py | 7 +++---- esphome/platformio/prefetch.py | 4 ++++ esphome/platformio/runner.py | 11 +++++++++++ tests/unit_tests/test_platformio_prefetch.py | 13 +++++++++++++ tests/unit_tests/test_platformio_runner.py | 15 +++++++++++++++ 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 12aaae315e..769b86519f 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -620,12 +620,11 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin - from platformio.project.helpers import get_project_cache_dir from platformio.registry.client import RegistryClient - # PlatformIO creates its HTTP cache dir without exist_ok, so two builds - # making their first registry lookup at once race on it - (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + from esphome.platformio.runner import ensure_http_cache_dir + + ensure_http_cache_dir() class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 991164eb99..a86b5a63b9 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -841,6 +841,10 @@ def _prefetch(build_dir: Path, env: str) -> None: from platformio.package.meta import PackageCompatibility, PackageSpec from platformio.platform.factory import PlatformFactory + from esphome.platformio.runner import ensure_http_cache_dir + + # The resolve and install pools each build their own registry client + ensure_http_cache_dir() platform_spec, config = _project_platform_and_config( build_dir / "platformio.ini", env ) diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index b9fbdec38d..cde79af0b6 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -106,6 +106,17 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +def ensure_http_cache_dir() -> None: + """Create PlatformIO's HTTP cache dir before anything resolves a package. + + PlatformIO creates it without exist_ok on the first registry lookup, so + concurrent lookups (the prefetch's resolve threads, or two builds) race. + """ + from platformio.project.helpers import get_project_cache_dir + + (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + + def patch_registry_private_packages() -> None: """Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup. diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 3f01fb22e6..8a60e9c388 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -561,6 +561,19 @@ def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: manager.set_download_utime.assert_not_called() +def test_prefetch_creates_http_cache_dir_first( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Eight resolve threads each build a registry client whose cache + init has no exist_ok; the dir must exist before any of them run.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path)) + with patch.object(pf, "_project_platform_and_config", return_value=(None, None)): + pf._prefetch(tmp_path, "testenv") + assert (tmp_path / "http").is_dir() + + def test_main_interrupt_exits_quietly(tmp_path: Path) -> None: """Ctrl-C reaches the child via the shared process group; it must exit without a traceback.""" diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index 007455f45a..b108cf298a 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import io +from pathlib import Path import sys from types import ModuleType from unittest.mock import Mock @@ -110,6 +111,20 @@ def test_main_applies_registry_private_packages_patch( assert order == ["patch", "pio"] +def test_ensure_http_cache_dir_is_idempotent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The dir exists before PlatformIO's non-exist_ok makedirs, and a + second caller (another thread or build) does not trip over it.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path / "c")) + + runner.ensure_http_cache_dir() + assert (tmp_path / "c" / "http").is_dir() + runner.ensure_http_cache_dir() + + # Snapshot PlatformIO's own probe at import, before any test can patch it _PIO_PROBE = RegistryClient.__dict__["allowed_private_packages"] From 3c88d39192d8b9cc6a198a2a2f510714e554cf46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:28:33 -1000 Subject: [PATCH 178/268] [remote_base] List the hob2hood, nexus and onkyori protocol defines in defines.h (#20577) --- esphome/core/defines.h | 3 +++ .../remote_base/test_protocol_defines.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/unit_tests/components/remote_base/test_protocol_defines.py diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0a876b4f0c..7c3c7d2861 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -169,6 +169,7 @@ #define USE_REMOTE_PROTOCOL_DYSON #define USE_REMOTE_PROTOCOL_GOBOX #define USE_REMOTE_PROTOCOL_HAIER +#define USE_REMOTE_PROTOCOL_HOB2HOOD #define USE_REMOTE_PROTOCOL_JVC #define USE_REMOTE_PROTOCOL_KEELOQ #define USE_REMOTE_PROTOCOL_LG @@ -177,6 +178,8 @@ #define USE_REMOTE_PROTOCOL_MIRAGE #define USE_REMOTE_PROTOCOL_NEC #define USE_REMOTE_PROTOCOL_NEXA +#define USE_REMOTE_PROTOCOL_NEXUS +#define USE_REMOTE_PROTOCOL_ONKYORI #define USE_REMOTE_PROTOCOL_PANASONIC #define USE_REMOTE_PROTOCOL_PIONEER #define USE_REMOTE_PROTOCOL_PRONTO diff --git a/tests/unit_tests/components/remote_base/test_protocol_defines.py b/tests/unit_tests/components/remote_base/test_protocol_defines.py new file mode 100644 index 0000000000..fdf6997440 --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_protocol_defines.py @@ -0,0 +1,20 @@ +"""Every gated protocol source needs its define listed for static analysis.""" + +from pathlib import Path + +from esphome.components.remote_base import _PROTOCOL_STEMS, protocol_define + +DEFINES_H = Path(__file__).parents[4] / "esphome" / "core" / "defines.h" + + +def test_every_protocol_define_is_listed_in_defines_h() -> None: + """A protocol compiled only behind its define would drop out of clang-tidy without this line.""" + defines = DEFINES_H.read_text(encoding="utf-8") + missing = [ + stem + for stem in _PROTOCOL_STEMS + if f"#define {protocol_define(stem)}\n" not in defines + ] + assert not missing, ( + f"Add #define {', '.join(protocol_define(s) for s in missing)} to {DEFINES_H.name}" + ) From c212950df5b7700fdc70d60488df67fd91f5c3c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:30:59 -1000 Subject: [PATCH 179/268] [wifi] Exclude the station connect from the loop blocking guard on LibreTiny (#20575) --- esphome/components/wifi/wifi_component_libretiny.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 2a9e682440..ca32f3d3ff 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -252,9 +252,14 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTING); s_ignored_disconnect_count = 0; - WiFiStatus status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), - ap.get_channel(), // 0 = auto - ap.has_bssid() ? ap.get_bssid().data() : NULL); + WiFiStatus status; + { + // The driver blocks until the join has started, or finished on Realtek + UnavoidableBlockingScope scope; + status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), + ap.get_channel(), // 0 = auto + ap.has_bssid() ? ap.get_bssid().data() : NULL); + } if (status != WL_CONNECTED) { ESP_LOGW(TAG, "WiFi.begin failed: %d", status); // Without this reset the state machine stays at CONNECTING and each retry From 057a0c5843662b675e31dad6a436a7b3e822d7f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:31:15 -1000 Subject: [PATCH 180/268] [api] Exclude the spare key refill from the loop blocking guard (#20574) --- esphome/components/api/api_server.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 7dad4e390e..f63039a971 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -237,6 +237,8 @@ void APIServer::refill_spare_ephemeral_() { return; } } + // Generating the key pair cannot be split across loop passes + UnavoidableBlockingScope scope; noise::prepare_spare_ephemeral(); } #endif From bde67d83f22596b96d595a8f6f57517faeb337b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:33:19 -1000 Subject: [PATCH 181/268] [noise] Exclude the handshake write step from the loop blocking guard (#20573) --- esphome/components/noise/noise_handshake.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index e0b7ca7c37..181dd63bb2 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -1,5 +1,6 @@ #include "noise_handshake.h" #ifdef USE_NOISE +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -111,6 +112,8 @@ int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) { int NoiseResponderHandshake::write_message(uint8_t *out, size_t capacity, size_t &out_len) { out_len = 0; + // The key agreement cannot be split across loop passes + UnavoidableBlockingScope scope; CpuFrequencyBoost boost; NoiseBuffer mbuf; noise_buffer_init(mbuf); From 698988669d225bc21f077f5d60f371fdf249bd24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:37:51 -1000 Subject: [PATCH 182/268] [remote_transmitter] Compile pronto, rc6, midea and lambda byte payloads in the test config (#20565) --- .../remote_transmitter/common-buttons.yaml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 52a43f41b9..7fa8cd1132 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -442,6 +442,54 @@ button: on_press: remote_transmitter.transmit_roomba: data: 0x88 + - platform: template + name: Pronto + on_press: + remote_transmitter.transmit_pronto: + data: "0000 006D 0001 0000 0015 0016" + - platform: template + name: RC6 + on_press: + remote_transmitter.transmit_rc6: + address: 0x00 + command: !lambda return 0x0C; + - platform: template + name: Midea + on_press: + remote_transmitter.transmit_midea: + code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF] + - platform: template + name: Midea lambda + on_press: + remote_transmitter.transmit_midea: + code: !lambda return {0xA2, 0x08, 0xFF, 0xFF, 0xFF}; + - platform: template + name: Haier lambda + on_press: + remote_transmitter.transmit_haier: + code: !lambda return {0xA6, 0xDA, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05}; + - platform: template + name: Mirage lambda + on_press: + remote_transmitter.transmit_mirage: + code: !lambda return {0x56, 0x77, 0x00, 0x00, 0x22, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + - platform: template + name: AEHA lambda + on_press: + remote_transmitter.transmit_aeha: + address: 0x8008 + data: !lambda return {0x00, 0x02, 0xFD, 0xFF}; + - platform: template + name: ABBWelcome fixed message id + on_press: + remote_transmitter.transmit_abbwelcome: + source_address: 0x123456 + destination_address: 0x654321 + three_byte_address: true + retransmission: true + message_type: 0x01 + message_id: 0x42 + data: [0x10, 0x20] - platform: template name: SomfyRTS on_press: From 9acc07a97350ca578e002ffcff300f1df01f2e87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:48:02 -1000 Subject: [PATCH 183/268] [remote_base] Generate transmit_pronto and transmit_rc6 with no C++ class (#20564) --- esphome/components/remote_base/__init__.py | 53 ++++++++++++------- .../components/remote_base/pronto_protocol.h | 13 ----- esphome/components/remote_base/rc6_protocol.h | 19 ------- .../remote_transmitter/config/rc6_pronto.yaml | 28 ++++++++++ .../remote_transmitter/test_rc6_pronto.py | 41 ++++++++++++++ 5 files changed, 104 insertions(+), 50 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/rc6_pronto.yaml create mode 100644 tests/component_tests/remote_transmitter/test_rc6_pronto.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index b0dc93e3b7..93e26165d6 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -308,26 +308,38 @@ def register_action( return decorator +ProtocolField = ( + tuple[str, str, SafeExpType] | automation.ApplyField | automation.ApplyCall +) + + +def _protocol_field( + field: ProtocolField, +) -> automation.ApplyField | automation.ApplyCall: + if not isinstance(field, tuple): + return field + key, member, type_ = field + return automation.ApplyField(key, f"data.{member} = {{}}", type_) + + def register_protocol_action( name: str, protocol: str, schema: cv.Schema, - *fields: tuple[str, str, SafeExpType], + *fields: ProtocolField, validate: Callable[[ConfigType], ConfigType] | None = None, ) -> None: """Register ``transmit_`` with no C++ action class. One generated function fills ``Data`` from ``(conf_key, member, type_)`` fields - and sends it; the action is a core ``ApplyAction``. + and sends it; the action is a core ``ApplyAction``. ``ApplyField`` and ``ApplyCall`` pass through + and target the call object, whose protocol data is ``data``. """ action, schema, validator = _transmit_action(name, schema, validate) builder = automation.apply_action_builder( action, schema, - *( - automation.ApplyField(key, f"data.{member} = {{}}", type_) - for key, member, type_ in fields - ), + *(_protocol_field(field) for field in fields), automation.ApplyField((CONF_REPEAT, CONF_TIMES), "set_send_times", cg.uint32), automation.ApplyField( (CONF_REPEAT, CONF_WAIT_TIME), "set_send_wait", cg.uint32 @@ -1206,7 +1218,7 @@ register_protocol_action( ProntoData, ProntoBinarySensor, ProntoTrigger, - ProntoAction, + _, ProntoDumper, ) = declare_protocol("Pronto") PRONTO_SCHEMA = cv.Schema( @@ -1240,10 +1252,10 @@ def pronto_dumper(var, config): pass -@register_action("pronto", ProntoAction, PRONTO_SCHEMA) -async def pronto_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.std_string) - cg.add(var.set_data(template_)) +# delta only matters when matching a received code +register_protocol_action( + "pronto", "Pronto", PRONTO_SCHEMA, (CONF_DATA, "data", cg.std_string) +) # Gobox @@ -1640,7 +1652,7 @@ register_protocol_action( # RC6 -RC6Data, RC6BinarySensor, RC6Trigger, RC6Action, RC6Dumper = declare_protocol("RC6") +RC6Data, RC6BinarySensor, RC6Trigger, _, RC6Dumper = declare_protocol("RC6") RC6_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint8_t, @@ -1674,12 +1686,17 @@ def rc6_dumper(var, config): pass -@register_action("rc6", RC6Action, RC6_SCHEMA) -async def rc6_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "rc6", + "RC6", + RC6_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + # Each action flips its own toggle bit, starting at 0: the lambda's static is per action + automation.ApplyCall( + "data.toggle = []() {{ static uint8_t toggle; return toggle++; }}()" + ), +) # RC Switch Raw diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index bfd04c5cd9..4119b87f28 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -37,17 +37,4 @@ class ProntoProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Pronto) -template class ProntoAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(std::string, data) - TEMPLATABLE_VALUE(int, delta) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ProntoData data{}; - data.data = this->data_.value(x...); - data.delta = this->delta_.value(x...); - ProntoProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc6_protocol.h b/esphome/components/remote_base/rc6_protocol.h index c4a2e8529b..cdeb8dfec7 100644 --- a/esphome/components/remote_base/rc6_protocol.h +++ b/esphome/components/remote_base/rc6_protocol.h @@ -22,23 +22,4 @@ class RC6Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(RC6) -template class RC6Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - RC6Data data{}; - data.mode = 0; - data.toggle = this->toggle_; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - RC6Protocol().encode(dst, data); - this->toggle_ = !this->toggle_; - } - - protected: - uint8_t toggle_{0}; -}; - } // namespace esphome::remote_base diff --git a/tests/component_tests/remote_transmitter/config/rc6_pronto.yaml b/tests/component_tests/remote_transmitter/config/rc6_pronto.yaml new file mode 100644 index 0000000000..a91040ebab --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/rc6_pronto.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: RC6 one + on_press: + - remote_transmitter.transmit_rc6: + address: 0x00 + command: 0x0C + - platform: template + name: RC6 two + on_press: + - remote_transmitter.transmit_rc6: + address: 0x00 + command: 0x0D + - platform: template + name: Pronto + on_press: + - remote_transmitter.transmit_pronto: + data: "0000 006D 0001 0000 0015 0016" diff --git a/tests/component_tests/remote_transmitter/test_rc6_pronto.py b/tests/component_tests/remote_transmitter/test_rc6_pronto.py new file mode 100644 index 0000000000..ba68f5a48b --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_rc6_pronto.py @@ -0,0 +1,41 @@ +"""transmit_rc6 keeps a toggle bit per action, and transmit_pronto sends its string.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome import automation +import esphome.codegen as cg +from esphome.components.remote_base import _protocol_field +from esphome.const import CONF_TEMPERATURE +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_rc6_toggle_and_pronto( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("rc6_pronto.yaml")) + bodies = protocol_call_bodies(main_cpp) + assert len(bodies) == 3 + toggle = ( + "apply_call.data.toggle = []() { static uint8_t toggle; return toggle++; }();" + ) + # Each RC6 action has its own generated function, so its own toggle static. + assert toggle in bodies[0] + assert toggle in bodies[1] + assert ( + 'apply_call.data.data = progmem_string(ESPHOME_F("0000 006D 0001 0000 0015 0016"));' + in bodies[2] + ) + assert "delta" not in bodies[2] + + +def test_apply_objects_pass_through() -> None: + call = automation.ApplyCall("stop()") + assert _protocol_field(call) is call + field = automation.ApplyField( + CONF_TEMPERATURE, "data.set_temperature({})", cg.float_ + ) + assert _protocol_field(field) is field + plain = _protocol_field((CONF_TEMPERATURE, "temperature", cg.float_)) + assert plain.target == "data.temperature = {}" From 780ccfc462e5c5836c60df3dd16f0846f7fd1fa7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 03:56:24 -1000 Subject: [PATCH 184/268] [adc] Skip the output raw, sample count, attenuation and sampling mode setters when they match the defaults (#20068) --- esphome/components/adc/adc_sensor.h | 8 ++--- esphome/components/adc/sensor.py | 32 ++++++++++++++----- .../adc/test_adc_defaults.yaml | 20 ++++++++++++ tests/component_tests/adc/test_adc_sensor.py | 16 ++++++++++ 4 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/adc/test_adc_defaults.yaml diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 46b7e7a2ff..f813087645 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -128,10 +128,10 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v #endif // USE_RP2 protected: - uint8_t sample_count_{1}; - bool output_raw_{false}; + uint8_t sample_count_{1}; // Must match DEFAULT_SAMPLES in sensor.py + bool output_raw_{false}; // Must match CONF_RAW default in sensor.py InternalGPIOPin *pin_; - SamplingMode sampling_mode_{SamplingMode::AVG}; + SamplingMode sampling_mode_{SamplingMode::AVG}; // Must match DEFAULT_SAMPLING_MODE in sensor.py #ifdef USE_ESP32 float sample_autorange_(); @@ -139,7 +139,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v bool autorange_{false}; adc_oneshot_unit_handle_t adc_handle_{nullptr}; adc_cali_handle_t calibration_handle_{nullptr}; - adc_atten_t attenuation_{ADC_ATTEN_DB_0}; + adc_atten_t attenuation_{ADC_ATTEN_DB_0}; // Must match DEFAULT_ATTENUATION in sensor.py adc_channel_t channel_{}; adc_unit_t adc_unit_{}; struct SetupFlags { diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 8cdea4f01a..b44d88d56e 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -48,6 +48,11 @@ AUTO_LOAD = ["voltage_sampler"] CONF_SAMPLES = "samples" CONF_SAMPLING_MODE = "sampling_mode" +# Keep in sync with the initializers in adc_sensor.h +DEFAULT_ATTENUATION = "0db" +DEFAULT_SAMPLING_MODE = "avg" +DEFAULT_SAMPLES = 1 + _attenuation = cv.enum(ATTENUATION_MODES, lower=True) _sampling_mode = cv.enum(SAMPLING_MODES, lower=True) @@ -65,7 +70,10 @@ def validate_config(config: ConfigType) -> ConfigType: ): raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'") - if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1: + if ( + config.get(CONF_ATTENUATION, None) == "auto" + and config.get(CONF_SAMPLES, DEFAULT_SAMPLES) > 1 + ): raise cv.Invalid( "Automatic attenuation cannot be used when multisampling is set" ) @@ -113,12 +121,16 @@ CONFIG_SCHEMA = cv.All( { cv.Required(CONF_PIN): validate_adc_pin, cv.Optional(CONF_RAW, default=False): cv.boolean, - cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All( + cv.SplitDefault(CONF_ATTENUATION, esp32=DEFAULT_ATTENUATION): cv.All( cv.only_on_esp32, _attenuation ), cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec), - cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255), - cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode, + cv.Optional(CONF_SAMPLES, default=DEFAULT_SAMPLES): cv.int_range( + min=1, max=255 + ), + cv.Optional( + CONF_SAMPLING_MODE, default=DEFAULT_SAMPLING_MODE + ): _sampling_mode, } ) .extend(cv.polling_component_schema("60s")), @@ -155,9 +167,13 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_output_raw(config[CONF_RAW])) - cg.add(var.set_sample_count(config[CONF_SAMPLES])) - cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE])) + # Skip the setters when the config matches the C++ initializers. + if config[CONF_RAW]: + cg.add(var.set_output_raw(True)) + if (samples := config[CONF_SAMPLES]) != DEFAULT_SAMPLES: + cg.add(var.set_sample_count(samples)) + if (sampling_mode := config[CONF_SAMPLING_MODE]) != DEFAULT_SAMPLING_MODE: + cg.add(var.set_sampling_mode(sampling_mode)) if CORE.is_esp32: # Re-enable ESP-IDF's ADC driver (excluded by default to save compile time) @@ -166,7 +182,7 @@ async def to_code(config: ConfigType) -> None: if attenuation := config.get(CONF_ATTENUATION): if attenuation == "auto": cg.add(var.set_autorange(cg.global_ns.true)) - else: + elif attenuation != DEFAULT_ATTENUATION: cg.add(var.set_attenuation(attenuation)) variant = get_esp32_variant() diff --git a/tests/component_tests/adc/test_adc_defaults.yaml b/tests/component_tests/adc/test_adc_defaults.yaml new file mode 100644 index 0000000000..1bee400e62 --- /dev/null +++ b/tests/component_tests/adc/test_adc_defaults.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: adc + pin: GPIO36 + name: ADC Defaults + id: adc_defaults + + - platform: adc + pin: GPIO39 + name: ADC Custom + id: adc_custom + raw: true + attenuation: 6db + sampling_mode: max + samples: 4 diff --git a/tests/component_tests/adc/test_adc_sensor.py b/tests/component_tests/adc/test_adc_sensor.py index a6d86f0305..3f1a4ae350 100644 --- a/tests/component_tests/adc/test_adc_sensor.py +++ b/tests/component_tests/adc/test_adc_sensor.py @@ -30,3 +30,19 @@ def test_adc_regular_pin_is_not_deprecated( assert "adc_voltage->set_is_temperature();" not in main_cpp assert caplog.text.count("`pin: TEMPERATURE` is deprecated") == 1 + + +def test_adc_default_setters_are_not_emitted( + generate_main: Callable[[str | Path], str], +) -> None: + """Raw, attenuation and sampling mode matching the C++ initializers are skipped.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_defaults.yaml") + + assert "adc_defaults->set_output_raw(" not in main_cpp + assert "adc_defaults->set_attenuation(" not in main_cpp + assert "adc_defaults->set_sampling_mode(" not in main_cpp + assert "adc_defaults->set_sample_count(" not in main_cpp + assert "adc_custom->set_output_raw(true);" in main_cpp + assert "adc_custom->set_attenuation(ADC_ATTEN_DB_6);" in main_cpp + assert "adc_custom->set_sampling_mode(adc::SamplingMode::MAX);" in main_cpp + assert "adc_custom->set_sample_count(4);" in main_cpp From 00872d433696fe775b40f2b01883c32fdccff4c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 03:56:53 -1000 Subject: [PATCH 185/268] [ethernet] Skip setters that pass the default PHY address, MDC, MDIO, clock and SPI interface (#20070) --- esphome/components/ethernet/__init__.py | 43 +++++++++++----- .../components/ethernet/ethernet_component.h | 12 ++--- .../ethernet/config/rmii_custom.yaml | 14 ++++++ .../ethernet/config/rmii_defaults.yaml | 13 +++++ .../ethernet/config/spi_s3_own_bus.yaml | 14 ++++++ .../ethernet/test_default_setters.py | 49 +++++++++++++++++++ 6 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 tests/component_tests/ethernet/config/rmii_custom.yaml create mode 100644 tests/component_tests/ethernet/config/rmii_defaults.yaml create mode 100644 tests/component_tests/ethernet/config/spi_s3_own_bus.yaml create mode 100644 tests/component_tests/ethernet/test_default_setters.py diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 58915ff96d..31e4952d8e 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -214,6 +214,15 @@ CLK_MODES = { spi_host_device_t = cg.global_ns.enum("spi_host_device_t") +# Must match the member initializers in ethernet_component.h; setters are skipped +# when the config already equals them. +DEFAULT_SPI_INTERFACE = "spi2" +DEFAULT_PHY_ADDR = 0 +DEFAULT_MDC_PIN = 23 +DEFAULT_MDIO_PIN = 18 +DEFAULT_CLK_MODE = "CLK_EXT_IN" +DEFAULT_CLK_PIN = 0 + SPI_INTERFACE_MAP = { "spi2": spi_host_device_t.SPI2_HOST, "spi3": spi_host_device_t.SPI3_HOST, @@ -437,7 +446,9 @@ RMII_SCHEMA = cv.All( " GPIO17_OUT -> mode: CLK_OUT, pin: GPIO17" ), cv.Required(CONF_CLK): CLK_SCHEMA, - cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_PHY_ADDR, default=DEFAULT_PHY_ADDR): cv.int_range( + min=0, max=31 + ), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), } @@ -454,7 +465,9 @@ GENERIC_SCHEMA = cv.All( { cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_PHY_ADDR, default=DEFAULT_PHY_ADDR): cv.int_range( + min=0, max=31 + ), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), } @@ -683,6 +696,15 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(final_step) +def _add_mdio_setters(var: cg.MockObj, config: ConfigType) -> None: + if (phy_addr := config[CONF_PHY_ADDR]) != DEFAULT_PHY_ADDR: + cg.add(var.set_phy_addr(phy_addr)) + if (mdc_pin := config[CONF_MDC_PIN]) != DEFAULT_MDC_PIN: + cg.add(var.set_mdc_pin(mdc_pin)) + if (mdio_pin := config[CONF_MDIO_PIN]) != DEFAULT_MDIO_PIN: + cg.add(var.set_mdio_pin(mdio_pin)) + + async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: from esphome.components.esp32 import ( add_idf_component, @@ -701,7 +723,8 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) - cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) + if (interface := config[CONF_INTERFACE]) != DEFAULT_SPI_INTERFACE: + cg.add(var.set_interface(SPI_INTERFACE_MAP[interface])) cg.add(var.set_cs_pin(config[CONF_CS_PIN])) if CONF_INTERRUPT_PIN in config: cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN])) @@ -730,18 +753,16 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_ETH_USE_OPENETH", True) elif config[CONF_TYPE] in ("GENERIC", "YT8531"): # RGMII data pins come from the IDF default config; set MDC/MDIO + PHY addr. - cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) - cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) - cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) + _add_mdio_setters(var, config) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) _add_phy_registers(var, config) else: - cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) - cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) - cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) - cg.add(var.set_clk_mode(config[CONF_CLK][CONF_MODE])) - cg.add(var.set_clk_pin(config[CONF_CLK][CONF_PIN])) + _add_mdio_setters(var, config) + if (clk_mode := config[CONF_CLK][CONF_MODE]) != DEFAULT_CLK_MODE: + cg.add(var.set_clk_mode(clk_mode)) + if (clk_pin := config[CONF_CLK][CONF_PIN]) != DEFAULT_CLK_PIN: + cg.add(var.set_clk_pin(clk_pin)) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) _add_phy_registers(var, config) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 44c4d6733d..8d5552c6cc 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -271,7 +271,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; - spi_host_device_t interface_{SPI2_HOST}; + spi_host_device_t interface_{SPI2_HOST}; // Must match DEFAULT_SPI_INTERFACE in __init__.py #ifdef USE_SPI // When set, the SPI bus is owned and initialized by this spi component // and the ethernet chip only adds a device to it. @@ -283,16 +283,16 @@ class EthernetComponent final : public Component { #else // Group all 32-bit members first int power_pin_{-1}; - emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN}; + emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN}; // Must match DEFAULT_CLK_MODE in __init__.py #ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT StaticVector phy_registers_{}; #endif // Group all 8-bit members together - uint8_t clk_pin_{0}; - uint8_t phy_addr_{0}; - uint8_t mdc_pin_{23}; - uint8_t mdio_pin_{18}; + uint8_t clk_pin_{0}; // Must match DEFAULT_CLK_PIN in __init__.py + uint8_t phy_addr_{0}; // Must match DEFAULT_PHY_ADDR in __init__.py + uint8_t mdc_pin_{23}; // Must match DEFAULT_MDC_PIN in __init__.py + uint8_t mdio_pin_{18}; // Must match DEFAULT_MDIO_PIN in __init__.py #endif // USE_ETHERNET_SPI // ESP32 pointers diff --git a/tests/component_tests/ethernet/config/rmii_custom.yaml b/tests/component_tests/ethernet/config/rmii_custom.yaml new file mode 100644 index 0000000000..291753592c --- /dev/null +++ b/tests/component_tests/ethernet/config/rmii_custom.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32dev + +ethernet: + type: LAN8720 + mdc_pin: GPIO4 + mdio_pin: GPIO5 + phy_addr: 1 + clk: + mode: CLK_OUT + pin: GPIO17 diff --git a/tests/component_tests/ethernet/config/rmii_defaults.yaml b/tests/component_tests/ethernet/config/rmii_defaults.yaml new file mode 100644 index 0000000000..7f39b31dfd --- /dev/null +++ b/tests/component_tests/ethernet/config/rmii_defaults.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + board: esp32dev + +ethernet: + type: LAN8720 + mdc_pin: GPIO23 + mdio_pin: GPIO18 + clk: + mode: CLK_EXT_IN + pin: GPIO0 diff --git a/tests/component_tests/ethernet/config/spi_s3_own_bus.yaml b/tests/component_tests/ethernet/config/spi_s3_own_bus.yaml new file mode 100644 index 0000000000..3f21fcd2c6 --- /dev/null +++ b/tests/component_tests/ethernet/config/spi_s3_own_bus.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + +ethernet: + type: W5500 + clk_pin: GPIO12 + mosi_pin: GPIO11 + miso_pin: GPIO13 + cs_pin: GPIO10 + interrupt_pin: GPIO4 + clock_speed: 20MHz diff --git a/tests/component_tests/ethernet/test_default_setters.py b/tests/component_tests/ethernet/test_default_setters.py new file mode 100644 index 0000000000..4658e8a4fb --- /dev/null +++ b/tests/component_tests/ethernet/test_default_setters.py @@ -0,0 +1,49 @@ +"""Ethernet setters are skipped when the config matches the C++ initializers.""" + +from collections.abc import Callable +from pathlib import Path + +_MDIO_SETTERS = ("set_phy_addr(", "set_mdc_pin(", "set_mdio_pin(") + + +def test_rmii_defaults_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("rmii_defaults.yaml")) + + for setter in (*_MDIO_SETTERS, "set_clk_mode(", "set_clk_pin("): + assert setter not in main_cpp + + +def test_rmii_custom_values_are_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("rmii_custom.yaml")) + + assert "set_phy_addr(1);" in main_cpp + assert "set_mdc_pin(4);" in main_cpp + assert "set_mdio_pin(5);" in main_cpp + assert "set_clk_mode(::EMAC_CLK_OUT);" in main_cpp + assert "set_clk_pin(17);" in main_cpp + + +def test_spi2_interface_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ESP32-S3 defaults to spi2, which is the C++ initializer.""" + main_cpp = generate_main(component_config_path("spi_s3_own_bus.yaml")) + + assert "set_interface(" not in main_cpp + + +def test_spi3_interface_is_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Classic ESP32 defaults to spi3, so the setter is still needed.""" + main_cpp = generate_main(component_config_path("spi_own_bus.yaml")) + + assert "set_interface(::SPI3_HOST);" in main_cpp From 9e8845885b0f3ab3afa84a81ee463d48a1fc707e Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 12 Oct 2026 11:22:59 -0400 Subject: [PATCH 186/268] [audio] Bump microFLAC to v0.3.0 (#20591) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 1480faa8c3..332638e75b 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -391,7 +391,7 @@ async def to_code(config: ConfigType) -> None: # Adds a define and IDF component for legacy `audio_decoder.cpp`. if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") - add_idf_component(name="esphome/micro-flac", ref="0.2.0") + add_idf_component(name="esphome/micro-flac", ref="0.3.0") _emit_memory_pair( data.flac.buffer_memory, "CONFIG_MICRO_FLAC_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 670145cfe0..1ede1f4d8d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-decoder: version: 0.4.0 esphome/micro-flac: - version: 0.2.0 + version: 0.3.0 esphome/micro-mp3: version: 0.4.0 esphome/micro-opus: From 72ea9ae4585d7d6b91e8a02a0e871587cafd1c35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:14:19 -1000 Subject: [PATCH 187/268] [remote_base] Generate the byte payload transmit actions with no C++ class (#20568) --- esphome/components/remote_base/__init__.py | 168 ++++++++---------- .../remote_base/abbwelcome_protocol.h | 35 +--- .../components/remote_base/aeha_protocol.cpp | 3 +- .../components/remote_base/aeha_protocol.h | 18 +- .../components/remote_base/haier_protocol.cpp | 3 +- .../components/remote_base/haier_protocol.h | 13 +- .../components/remote_base/midea_protocol.h | 12 -- .../remote_base/mirage_protocol.cpp | 11 +- .../components/remote_base/mirage_protocol.h | 13 +- .../components/remote_base/remote_base.cpp | 5 + esphome/components/remote_base/remote_base.h | 15 +- .../remote_transmitter/__init__.py | 2 +- .../config/byte_payloads.yaml | 33 ++++ .../test_abbwelcome_payload_tables.py | 12 +- .../remote_transmitter/test_byte_payloads.py | 33 ++++ .../remote_transmitter/test_ir_codes.py | 13 +- .../remote_base/ir_code_encoders_test.cpp | 51 ++---- 17 files changed, 223 insertions(+), 217 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/byte_payloads.yaml create mode 100644 tests/component_tests/remote_transmitter/test_byte_payloads.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 93e26165d6..45873f2385 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -322,18 +322,33 @@ def _protocol_field( return automation.ApplyField(key, f"data.{member} = {{}}", type_) +_BYTES = cg.std_vector.template(cg.uint8) + + +def _code_table(config: ConfigType, value: list[int]) -> str: + return f"{automation.progmem_bytes('remote_base_code', value)}, {len(value)}" + + +def _bytes_field(conf_key: str, target: str) -> automation.ApplyField: + """A constant's flash table and length, or a lambda's vector; the callee takes both.""" + return automation.ApplyField(conf_key, target, _BYTES, const_fn=_code_table) + + def register_protocol_action( name: str, protocol: str, schema: cv.Schema, *fields: ProtocolField, validate: Callable[[ConfigType], ConfigType] | None = None, + call: str = "protocol_call", ) -> None: """Register ``transmit_`` with no C++ action class. One generated function fills ``Data`` from ``(conf_key, member, type_)`` fields and sends it; the action is a core ``ApplyAction``. ``ApplyField`` and ``ApplyCall`` pass through and target the call object, whose protocol data is ``data``. + ``call="encode_call"`` targets a plain transmit call whose ``encode()`` runs one of the + protocol's encoders, for payloads it encodes straight from a pointer and length. """ action, schema, validator = _transmit_action(name, schema, validate) builder = automation.apply_action_builder( @@ -344,7 +359,7 @@ def register_protocol_action( automation.ApplyField( (CONF_REPEAT, CONF_WAIT_TIME), "set_send_wait", cg.uint32 ), - call=f"protocol_call<{ns.class_(f'{protocol}Protocol')}>", + call=f"{call}<{ns.class_(f'{protocol}Protocol')}>", id_key=CONF_TRANSMITTER_ID, ) @@ -2375,10 +2390,7 @@ register_protocol_action( # Midea -MideaData, MideaBinarySensor, MideaTrigger, MideaAction, MideaDumper = declare_protocol( - "Midea" -) -MideaAction = ns.class_("MideaAction", RemoteTransmitterActionBase) +MideaData, MideaBinarySensor, MideaTrigger, _, MideaDumper = declare_protocol("Midea") MIDEA_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=5, max=5)), @@ -2401,26 +2413,17 @@ def midea_dumper(var, config): pass -async def _code_bytes( - value: Any, args: TemplateArgsType, set_template: MockObj, set_static: MockObj -) -> None: - """Constant codes share one PROGMEM table pool across the remote protocols.""" - await automation.templatable_bytes( - value, args, set_template, set_static, "remote_base_code" - ) - - -@register_action("midea", MideaAction, MIDEA_SCHEMA) -async def midea_action(var, config, args): - await _code_bytes( - config[CONF_CODE], args, var.set_code_template, var.set_code_static - ) +register_protocol_action( + "midea", + "Midea", + MIDEA_SCHEMA, + _bytes_field(CONF_CODE, f"data = {MideaData}({{}})"), + automation.ApplyCall("data.finalize()"), +) # AEHA -AEHAData, AEHABinarySensor, AEHATrigger, AEHAAction, AEHADumper = declare_protocol( - "AEHA" -) +AEHAData, AEHABinarySensor, AEHATrigger, _, AEHADumper = declare_protocol("AEHA") AEHA_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint16_t, @@ -2452,9 +2455,9 @@ def aeha_dumper(var, config): pass -@register_action( +register_protocol_action( "aeha", - AEHAAction, + "AEHA", AEHA_SCHEMA.extend( { cv.Optional(CONF_CARRIER_FREQUENCY, default="38000Hz"): cv.All( @@ -2462,15 +2465,18 @@ def aeha_dumper(var, config): ), } ), + automation.ApplyField( + CONF_CARRIER_FREQUENCY, "get_data()->set_carrier_frequency({})", cg.uint32 + ), + automation.ApplyCall( + "encode({}, {})", + ( + (CONF_ADDRESS, cg.uint16), + (CONF_DATA, _BYTES, _code_table), + ), + ), + call="encode_call", ) -async def aeha_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - await _code_bytes( - config[CONF_DATA], args, var.set_data_template, var.set_data_static - ) - templ = await cg.templatable(config[CONF_CARRIER_FREQUENCY], args, cg.uint32) - cg.add(var.set_carrier_frequency(templ)) # Hob2Hood @@ -2529,10 +2535,7 @@ register_protocol_action( # Haier -HaierData, HaierBinarySensor, HaierTrigger, HaierAction, HaierDumper = declare_protocol( - "Haier" -) -HaierAction = ns.class_("HaierAction", RemoteTransmitterActionBase) +HaierData, HaierBinarySensor, HaierTrigger, _, HaierDumper = declare_protocol("Haier") HAIER_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.All( @@ -2562,11 +2565,13 @@ def haier_dumper(var, config): pass -@register_action("haier", HaierAction, HAIER_SCHEMA) -async def haier_action(var, config, args): - await _code_bytes( - config[CONF_CODE], args, var.set_code_template, var.set_code_static - ) +register_protocol_action( + "haier", + "Haier", + HAIER_SCHEMA, + _bytes_field(CONF_CODE, "encode({})"), + call="encode_call", +) # ABBWelcome @@ -2574,7 +2579,7 @@ async def haier_action(var, config, args): ABBWelcomeData, ABBWelcomeBinarySensor, ABBWelcomeTrigger, - ABBWelcomeAction, + _, ABBWelcomeDumper, ) = declare_protocol("ABBWelcome") @@ -2626,49 +2631,28 @@ def abbwelcome_dumper(var, config): pass -@register_action("abbwelcome", ABBWelcomeAction, ABB_WELCOME_SCHEMA) -async def abbwelcome_action(var, config, args): - cg.add( - var.set_three_byte_address( - await cg.templatable(config[CONF_THREE_BYTE_ADDRESS], args, cg.bool_) - ) - ) - cg.add( - var.set_source_address( - await cg.templatable(config[CONF_SOURCE_ADDRESS], args, cg.uint32) - ) - ) - cg.add( - var.set_destination_address( - await cg.templatable(config[CONF_DESTINATION_ADDRESS], args, cg.uint32) - ) - ) - cg.add( - var.set_retransmission( - await cg.templatable(config[CONF_RETRANSMISSION], args, cg.bool_) - ) - ) - cg.add( - var.set_message_type( - await cg.templatable(config[CONF_MESSAGE_TYPE], args, cg.uint8) - ) - ) - template_ = await cg.templatable(CONF_MESSAGE_ID not in config, args, cg.bool_) - cg.add(var.set_auto_message_id(template_)) - if CONF_MESSAGE_ID in config: - cg.add( - var.set_message_id( - await cg.templatable(config[CONF_MESSAGE_ID], args, cg.uint8) - ) - ) - # 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", - ) +register_protocol_action( + "abbwelcome", + "ABBWelcome", + ABB_WELCOME_SCHEMA, + # First: the address length decides where the other fields go + automation.ApplyField( + CONF_THREE_BYTE_ADDRESS, "data.set_three_byte_address({})", cg.bool_ + ), + automation.ApplyField( + CONF_SOURCE_ADDRESS, "data.set_source_address({})", cg.uint32 + ), + automation.ApplyField( + CONF_DESTINATION_ADDRESS, "data.set_destination_address({})", cg.uint32 + ), + automation.ApplyField(CONF_RETRANSMISSION, "data.set_retransmission({})", cg.bool_), + automation.ApplyField(CONF_MESSAGE_TYPE, "data.set_message_type({})", cg.uint8), + # Without message_id, finalize() picks one + automation.ApplyCall("data.auto_message_id = true"), + automation.ApplyField(CONF_MESSAGE_ID, "data.set_fixed_message_id({})", cg.uint8), + _bytes_field(CONF_DATA, "data.set_data({})"), + automation.ApplyCall("data.finalize()"), +) # Mirage @@ -2676,7 +2660,7 @@ async def abbwelcome_action(var, config, args): MirageData, MirageBinarySensor, MirageTrigger, - MirageAction, + _, MirageDumper, ) = declare_protocol("Mirage") @@ -2702,11 +2686,13 @@ def mirage_dumper(var, config): pass -@register_action("mirage", MirageAction, MIRAGE_SCHEMA) -async def mirage_action(var, config, args): - await _code_bytes( - config[CONF_CODE], args, var.set_code_template, var.set_code_static - ) +register_protocol_action( + "mirage", + "Mirage", + MIRAGE_SCHEMA, + _bytes_field(CONF_CODE, "encode({})"), + call="encode_call", +) # Toto diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 901950d363..ecd27a95a5 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "remote_base.h" #include @@ -111,13 +112,19 @@ class ABBWelcomeData { return (this->data_[7] << 16) + (this->data_[8] << 8) + this->data_[9]; } void set_message_id(uint8_t message_id) { this->data_[4 + 2 * this->get_address_length()] = message_id; } + /// A configured id, which finalize() keeps instead of picking one + void set_fixed_message_id(uint8_t message_id) { + this->set_message_id(message_id); + this->auto_message_id = false; + } uint8_t get_message_id() const { return this->data_[4 + 2 * this->get_address_length()]; } + /// data may be a PROGMEM table 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, size, this->data_.begin() + 5 + 2 * this->get_address_length()); + progmem_memcpy(this->data_.data() + 5 + 2 * this->get_address_length(), data, size); } void set_data(const std::vector &data) { this->set_data(data.data(), data.size()); } std::vector get_data() const { @@ -226,30 +233,4 @@ class ABBWelcomeBinarySensor : public RemoteReceiverBinarySensorBase { using ABBWelcomeTrigger = RemoteReceiverTrigger; using ABBWelcomeDumper = RemoteReceiverDumper; -template class ABBWelcomeAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(uint32_t, source_address) - TEMPLATABLE_VALUE(uint32_t, destination_address) - TEMPLATABLE_VALUE(bool, retransmission) - TEMPLATABLE_VALUE(bool, three_byte_address) - TEMPLATABLE_VALUE(uint8_t, message_type) - TEMPLATABLE_VALUE(uint8_t, message_id) - TEMPLATABLE_VALUE(bool, auto_message_id) - TEMPLATABLE_BYTES(data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ABBWelcomeData data; - data.set_three_byte_address(this->three_byte_address_.value(x...)); - data.set_source_address(this->source_address_.value(x...)); - data.set_destination_address(this->destination_address_.value(x...)); - data.set_retransmission(this->retransmission_.value(x...)); - 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...); - this->data_.template visit( - [&data](const uint8_t *bytes, size_t len) { data.set_data(bytes, len); }, x...); - data.finalize(); - ABBWelcomeProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp index 3263f6b220..70454dbc49 100644 --- a/esphome/components/remote_base/aeha_protocol.cpp +++ b/esphome/components/remote_base/aeha_protocol.cpp @@ -1,4 +1,5 @@ #include "aeha_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include @@ -28,7 +29,7 @@ void AEHAProtocol::encode(RemoteTransmitData *dst, uint16_t address, const uint8 } for (size_t i = 0; i < len; i++) { - const uint8_t bit = data[i]; + const uint8_t bit = progmem_read_byte(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 f6064dd5df..ab762612e1 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -18,7 +18,11 @@ class AEHAProtocol : public RemoteProtocol { void encode(RemoteTransmitData *dst, const AEHAData &data) { this->encode(dst, data.address, data.data.data(), data.data.size()); } + /// data may be a PROGMEM table void encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, uint16_t address, const std::vector &data) { + this->encode(dst, address, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const AEHAData &data); @@ -28,18 +32,4 @@ class AEHAProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(AEHA) -template class AEHAAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_BYTES(data) - TEMPLATABLE_VALUE(uint32_t, carrier_frequency); - - void encode(RemoteTransmitData *dst, Ts... x) override { - const uint16_t address = this->address_.value(x...); - dst->set_carrier_frequency(this->carrier_frequency_.value(x...)); - this->data_.visit( - [dst, address](const uint8_t *data, size_t len) { AEHAProtocol().encode(dst, address, data, len); }, x...); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index e7fa563fb5..07e3793520 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -1,4 +1,5 @@ #include "haier_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -39,7 +40,7 @@ void HaierProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t dst->mark(BIT_MARK_US); uint8_t checksum = 0; for (size_t i = 0; i < len; i++) { - const uint8_t item = data[i]; + const uint8_t item = progmem_read_byte(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 770784b0fc..0594049471 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -14,7 +14,11 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: void encode(RemoteTransmitData *dst, const HaierData &data) { this->encode(dst, data.data.data(), data.data.size()); } + /// data may be a PROGMEM table void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, const std::vector &data) { + this->encode(dst, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const HaierData &data); @@ -24,13 +28,4 @@ class HaierProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Haier) -template class HaierAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_BYTES(code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - this->code_.visit([dst](const uint8_t *data, size_t len) { HaierProtocol().encode(dst, data, len); }, x...); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 19521e8f6d..572179605a 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -78,16 +78,4 @@ class MideaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Midea) -template class MideaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_BYTES(code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - MideaData data = this->code_.is_static() ? MideaData(this->code_.data(), this->code_.size()) - : MideaData(this->code_.value(x...)); - data.finalize(); - MideaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/mirage_protocol.cpp b/esphome/components/remote_base/mirage_protocol.cpp index 23c6bd21e8..099388f8ec 100644 --- a/esphome/components/remote_base/mirage_protocol.cpp +++ b/esphome/components/remote_base/mirage_protocol.cpp @@ -1,7 +1,10 @@ #include "mirage_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::remote_base { ESPHOME_LOG_TAG(TAG, "remote.mirage"); @@ -17,8 +20,12 @@ constexpr unsigned int MIRAGE_IR_PACKET_BIT_SIZE = 120; constexpr size_t MIRAGE_MAX_DATA_BYTES = (MIRAGE_IR_PACKET_BIT_SIZE / 8); void MirageProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { + // data may be a PROGMEM table, so log a RAM copy + uint8_t log_buf[MIRAGE_MAX_DATA_BYTES]; + const size_t log_len = std::min(len, MIRAGE_MAX_DATA_BYTES); + progmem_memcpy(log_buf, data, log_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, len)); + ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, log_buf, log_len)); dst->set_carrier_frequency(38000); dst->reserve(5 + ((len + 1) * 2)); dst->mark(HEADER_MARK_US); @@ -26,7 +33,7 @@ void MirageProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t dst->mark(BIT_MARK_US); uint8_t checksum = 0; for (size_t i = 0; i < len; i++) { - const uint8_t item = data[i]; + const uint8_t item = progmem_read_byte(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 923d412e84..ae6ed0a8e9 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -16,7 +16,11 @@ class MirageProtocol : public RemoteProtocol { void encode(RemoteTransmitData *dst, const MirageData &data) { this->encode(dst, data.data.data(), data.data.size()); } + /// data may be a PROGMEM table void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, const std::vector &data) { + this->encode(dst, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const MirageData &data); @@ -26,13 +30,4 @@ class MirageProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Mirage) -template class MirageAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_BYTES(code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - this->code_.visit([dst](const uint8_t *data, size_t len) { MirageProtocol().encode(dst, data, len); }, x...); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 38db90c97b..3c5291ffb2 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -182,6 +182,11 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) { /* RemoteTransmitterBase */ +RemoteTransmitterBase::TransmitCall RemoteTransmitterBase::transmit() { + this->temp_.reset(); + return TransmitCall(this, this->take_seq_()); +} + void RemoteTransmitterBase::transmit_encoded_(EncodeFn encode, const void *data, uint32_t send_times, uint32_t send_wait) { auto call = this->transmit(); diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index fc2f3a7130..783343c5a2 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -187,10 +187,7 @@ class RemoteTransmitterBase : public RemoteComponentBase { uint16_t seq_; }; - TransmitCall transmit() { - this->temp_.reset(); - return TransmitCall(this, this->take_seq_()); - } + TransmitCall transmit(); template void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { this->transmit_encoded_(&encode_thunk, &data, send_times, send_wait); @@ -213,6 +210,16 @@ class RemoteTransmitterBase : public RemoteComponentBase { template ProtocolCall protocol_call() { return ProtocolCall(this); } + /// A transmit call whose encode() runs one of Protocol's encoders on its data, for a payload + /// the protocol encodes straight from a pointer and length; the call a generated action builds. + template class EncodeCall : public TransmitCall { + public: + explicit EncodeCall(TransmitCall call) : TransmitCall(call) {} + template void encode(const Args &...args) { Protocol().encode(this->get_data(), args...); } + }; + template EncodeCall encode_call() { + return EncodeCall(this->transmit()); + } protected: using EncodeFn = void (*)(RemoteTransmitData *dst, const void *data); diff --git a/tests/component_tests/remote_transmitter/__init__.py b/tests/component_tests/remote_transmitter/__init__.py index 7b2d567ec7..da300b47b1 100644 --- a/tests/component_tests/remote_transmitter/__init__.py +++ b/tests/component_tests/remote_transmitter/__init__.py @@ -3,7 +3,7 @@ import re _PROTOCOL_CALL_BODY = re.compile( - r"auto apply_call = \S+->protocol_call\(\);\n(.*?)apply_call\.perform\(\);", + r"auto apply_call = \S+->(?:protocol|encode)_call\(\);\n(.*?)apply_call\.perform\(\);", re.DOTALL, ) diff --git a/tests/component_tests/remote_transmitter/config/byte_payloads.yaml b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml new file mode 100644 index 0000000000..41eefdad7f --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: ABBWelcome fixed id + on_press: + - remote_transmitter.transmit_abbwelcome: + source_address: 0x123456 + destination_address: 0x56789A + three_byte_address: true + message_type: 0x01 + message_id: 0x42 + data: [0x10] + - platform: template + name: AEHA lambda + on_press: + - remote_transmitter.transmit_aeha: + address: !lambda return 0x8008; + carrier_frequency: 36700Hz + data: !lambda return {0x00, 0x02}; + - platform: template + name: Midea lambda + on_press: + - remote_transmitter.transmit_midea: + code: !lambda return {0xA2, 0x08, 0xFF, 0xFF, 0xFF}; diff --git a/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py index a653b5137e..ea1dbcf294 100644 --- a/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py +++ b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py @@ -9,13 +9,17 @@ 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.""" + """Equal data shares one table, empty data is a null table, lambdas are called inline.""" 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 + assert main_cpp.count(f"apply_call.data.set_data({tables[0][0]}, 3);") == 2 + # Empty data is a null table; missing data leaves the default empty payload + assert main_cpp.count("apply_call.data.set_data(nullptr, 0);") == 1 + assert ( + main_cpp.count("apply_call.data.set_data([]() -> std::vector {") == 1 + ) + assert "std::vector{" not in main_cpp diff --git a/tests/component_tests/remote_transmitter/test_byte_payloads.py b/tests/component_tests/remote_transmitter/test_byte_payloads.py new file mode 100644 index 0000000000..5bdd60cae0 --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_byte_payloads.py @@ -0,0 +1,33 @@ +"""The byte payload transmit actions as generated code.""" + +from collections.abc import Callable +from pathlib import Path + +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_byte_payloads( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("byte_payloads.yaml")) + abb, aeha, midea = protocol_call_bodies(main_cpp) + # The address length goes first, a configured id turns the automatic one off + assert abb.index("set_three_byte_address(true);") < abb.index("set_source_address(") + assert abb.index("auto_message_id = true;") < abb.index( + "set_fixed_message_id(0x42);" + ) + assert abb.rstrip().endswith("apply_call.data.finalize();") + # The carrier is set before the payload is encoded + assert aeha.index("set_carrier_frequency(36700);") < aeha.index( + "apply_call.encode(" + ) + assert ( + "apply_call.encode(static_cast(0x8008), []() -> std::vector {" + in aeha + ) + assert ( + "apply_call.data = remote_base::MideaData([]() -> std::vector {" + in midea + ) + assert midea.rstrip().endswith("apply_call.data.finalize();") diff --git a/tests/component_tests/remote_transmitter/test_ir_codes.py b/tests/component_tests/remote_transmitter/test_ir_codes.py index 9be34941b6..8d08e52ffd 100644 --- a/tests/component_tests/remote_transmitter/test_ir_codes.py +++ b/tests/component_tests/remote_transmitter/test_ir_codes.py @@ -1,4 +1,4 @@ -"""Constant IR byte codes live in shared PROGMEM tables; lambdas stay lambdas.""" +"""Constant IR byte codes are encoded from shared PROGMEM tables; lambdas are called inline.""" from collections.abc import Callable from pathlib import Path @@ -16,9 +16,10 @@ def test_ir_codes_use_shared_progmem_tables( ) assert len(tables) == 4 # Midea (shared by two actions), AEHA, Haier, Mirage midea = tables[0] - assert main_cpp.count(f"->set_code_static({midea}, 5);") == 2 - assert re.search(r"->set_data_static\(remote_base_code\w*, 6\);", main_cpp) - assert re.search(r"->set_code_static\(remote_base_code\w*, 13\);", main_cpp) - assert re.search(r"->set_code_static\(remote_base_code\w*, 14\);", main_cpp) - assert "->set_code_template([](" in main_cpp + assert main_cpp.count(f"apply_call.data = remote_base::MideaData({midea}, 5);") == 2 + assert re.search(r"apply_call\.encode\(0x8008, remote_base_code\w*, 6\);", main_cpp) + assert re.search(r"apply_call\.encode\(remote_base_code\w*, 13\);", main_cpp) + assert re.search(r"apply_call\.encode\(remote_base_code\w*, 14\);", main_cpp) + assert "apply_call.encode([]() -> std::vector {" in main_cpp + # Constant codes are read from the table, never copied into a vector assert "std::vector{" not in main_cpp diff --git a/tests/components/remote_base/ir_code_encoders_test.cpp b/tests/components/remote_base/ir_code_encoders_test.cpp index bffea25864..b003318a1f 100644 --- a/tests/components/remote_base/ir_code_encoders_test.cpp +++ b/tests/components/remote_base/ir_code_encoders_test.cpp @@ -62,27 +62,15 @@ RawTimings with_trailing_space(RawTimings timings) { 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); +// A constant table and a lambda's vector take the two encoder overloads generated actions call +template +void expect_pointer_and_vector_match(const uint8_t *code, size_t len, const std::vector &bytes, + const RawTimings &golden, const Lead &...lead) { + RemoteTransmitData fixed, from_vector; + P().encode(&fixed, lead..., code, len); + P().encode(&from_vector, lead..., bytes); + EXPECT_EQ(fixed.get_data(), golden); + EXPECT_EQ(from_vector.get_data(), golden); } template void expect_decodes_to(const RawTimings &timings, const std::vector &code) { @@ -94,25 +82,16 @@ template void expect_decodes_to(const RawTimings &timings, const std } // namespace -TEST(IrCodeEncodersTest, HaierStaticAndLambdaMatchGolden) { - expect_static_and_lambda_match>(HAIER_CODE, sizeof(HAIER_CODE), haier_code, HAIER_GOLDEN); +TEST(IrCodeEncodersTest, HaierPointerAndVectorMatchGolden) { + expect_pointer_and_vector_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, MiragePointerAndVectorMatchGolden) { + expect_pointer_and_vector_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, AehaPointerAndVectorMatchGolden) { + expect_pointer_and_vector_match(AEHA_CODE, sizeof(AEHA_CODE), aeha_code(), AEHA_GOLDEN, AEHA_ADDRESS); } TEST(IrCodeEncodersTest, StructEncodersMatchGolden) { From 2dad6e4f626ae9a6d3a8d6b665bcf9a584be916c Mon Sep 17 00:00:00 2001 From: Firat Hajiyev Date: Mon, 12 Oct 2026 13:15:13 -0400 Subject: [PATCH 188/268] [wifi] Reject unsupported WPA3 on ESP8266 (#20592) Co-authored-by: Firat17aze <187820330+Firat17aze@users.noreply.github.com> --- esphome/components/wifi/__init__.py | 7 ++ .../wifi/test_min_auth_mode.py | 79 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/component_tests/wifi/test_min_auth_mode.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 61f242d8ca..667e288fd7 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -345,6 +345,12 @@ def validate_variant(_): ) +def _validate_min_auth_mode(value: str) -> str: + if value == "WPA3" and CORE.is_esp8266: + raise cv.Invalid("ESP8266 does not support WPA3; use WPA or WPA2 instead") + return value + + def _apply_min_auth_mode_default(config): """Apply platform-specific default for min_auth_mode.""" if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): @@ -556,6 +562,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, cv.only_on([Platform.ESP32, Platform.ESP8266]), + _validate_min_auth_mode, ), cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) diff --git a/tests/component_tests/wifi/test_min_auth_mode.py b/tests/component_tests/wifi/test_min_auth_mode.py new file mode 100644 index 0000000000..e0e5ca9805 --- /dev/null +++ b/tests/component_tests/wifi/test_min_auth_mode.py @@ -0,0 +1,79 @@ +"""Platform restrictions for minimum Wi-Fi authentication modes.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import VARIANT_ESP32 +from esphome.components.wifi import CONF_MIN_AUTH_MODE, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(mode: str | None = None) -> dict: + config = {"ssid": "net", "password": "secret12", "use_address": "test.local"} + if mode is not None: + config[CONF_MIN_AUTH_MODE] = mode + return CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize("mode", ["WPA3", "wpa3"]) +def test_esp8266_rejects_wpa3( + set_core_config: SetCoreConfigCallable, mode: str +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="ESP8266 does not support WPA3") as exc: + _validate(mode) + assert exc.value.path == [CONF_MIN_AUTH_MODE] + + +@pytest.mark.parametrize( + ("platform", "mode"), + [ + (PlatformFramework.ESP8266_ARDUINO, "WPA"), + (PlatformFramework.ESP8266_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA"), + (PlatformFramework.ESP32_IDF, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA3"), + (PlatformFramework.ESP32_IDF, "wpa3"), + (PlatformFramework.ESP32_ARDUINO, "WPA"), + (PlatformFramework.ESP32_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_ARDUINO, "WPA3"), + ], +) +def test_supported_modes( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework, mode: str +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate(mode)[CONF_MIN_AUTH_MODE] == mode.upper() + + +@pytest.mark.parametrize( + "platform", + [ + PlatformFramework.ESP8266_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + ], +) +def test_default_remains_wpa2( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate()[CONF_MIN_AUTH_MODE] == "WPA2" From f854da4f8d7a3e368d49218fff1db2fafcaf8e7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:15:51 -1000 Subject: [PATCH 189/268] [cli] Add --prompt-ota-key to present a different key for one upload (#19397) --- esphome/__main__.py | 143 ++++++++++-- esphome/util.py | 11 + tests/unit_tests/test_main.py | 411 ++++++++++++++++++++++++++++++++++ tests/unit_tests/test_util.py | 23 ++ 4 files changed, 575 insertions(+), 13 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 77c1c9f1ad..0c164893df 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -48,8 +48,11 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, + KEY_CORE, KEY_ESP32, + KEY_TARGET_PLATFORM, KEY_VARIANT, + PLATFORM_HOST, SECRETS_FILES, ErrorFormat, Toolchain, @@ -70,6 +73,7 @@ from esphome.util import ( get_serial_ports, is_picotool_usb_permission_error, list_yaml_files, + read_secret_line, run_external_command, run_external_process, safe_print, @@ -1232,14 +1236,28 @@ def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] ) -> tuple[int, str | None]: host = devices[0] + port_type = get_port_type(host) + ota_key = getattr(args, "ota_key", None) + if ota_key and port_type in (PortType.SERIAL, PortType.BOOTSEL): + _LOGGER.warning( + "The presented OTA key only applies to network uploads; ignored for %s", + host, + ) + platform_upload = platform_hooks.get_platform_hook( CORE.target_platform, "upload_program" ) if platform_upload is not None and platform_upload(config, args, host): + if ota_key and port_type not in (PortType.SERIAL, PortType.BOOTSEL): + # A platform's own network path (nrf52 mcumgr) has no key handshake + _LOGGER.warning( + "The presented OTA key only applies to the %s OTA platform; " + "ignored for %s", + CONF_ESPHOME, + host, + ) return 0, host - port_type = get_port_type(host) - # MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by # _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths. is_partition_table = getattr(args, "partition_table", False) @@ -1282,6 +1300,8 @@ def upload_program( network_devices = _resolve_network_devices(devices, config, args) if chosen_platform == CONF_WEB_SERVER: + if ota_key: + raise EsphomeError(OTA_KEY_PLATFORM_ERROR) if is_partition_table or is_bootloader: raise EsphomeError( f"{option_string} is only supported with the esphome OTA platform; " @@ -1295,6 +1315,17 @@ def upload_program( return _upload_via_native_api(config, network_devices, args) +def _ota_upload_platforms(config: ConfigType) -> dict[str, None]: + """The network OTA platforms the config provides, one entry each, in + config order; the web_server final-validate hook merges duplicates.""" + return { + ota_platform: None + for ota_item in config.get(CONF_OTA, []) + if (ota_platform := ota_item.get(CONF_PLATFORM)) + in (CONF_ESPHOME, CONF_WEB_SERVER) + } + + def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: """Pick the OTA platform to use, optionally honoring ``--ota-platform``. @@ -1307,16 +1338,7 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to ``web_server`` only when that is the only available platform. """ - # Use a dict (insertion-ordered) instead of a list so error messages and - # membership checks see one entry per platform even if the user has - # multiple ``ota:`` items of the same platform; the web_server OTA - # platform's final-validate hook merges duplicates anyway. - available: dict[str, None] = {} - for ota_item in config.get(CONF_OTA, []): - ota_platform = ota_item.get(CONF_PLATFORM) - if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER): - available[ota_platform] = None - + available = _ota_upload_platforms(config) if not available: raise EsphomeError( f"Cannot upload Over the Air as the {CONF_OTA} configuration is not " @@ -1356,7 +1378,12 @@ def _upload_via_native_api( noise_psk = None plaintext_fallback = False allow_plaintext_upload = False - if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: + if ota_key := getattr(args, "ota_key", None): + # The user presents a key the device still runs instead of the + # configured one (a key change in progress); an explicit key never + # downgrades to plaintext + noise_psk = ota_key + elif (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: noise_psk = encryption_conf.get(CONF_KEY) allow_plaintext_upload = bool( encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD) @@ -1738,7 +1765,60 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: return 0 +ENV_OTA_KEY = "ESPHOME_OTA_KEY" +OTA_KEY_PLATFORM_ERROR = ( + f"--prompt-ota-key and {ENV_OTA_KEY} only apply to the {CONF_ESPHOME} OTA " + "platform; to flash by serial instead, name the port with --device" +) + + +def _read_ota_key(args: ArgsProtocol, config: ConfigType) -> None: + """Take the key to present to the device for this upload, from the + prompt or from ESPHOME_OTA_KEY, before any compile so the prompt is + not buried in build output.""" + prompt = getattr(args, "prompt_ota_key", False) + env_key = getattr(args, "env_ota_key", None) + if not prompt and env_key is None: + return + if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_HOST: + _LOGGER.warning("A host program runs locally; the presented OTA key is ignored") + return + # The HTTP path has no key handshake; decide before prompting or + # compiling. A serial target is a serial flash: the key is not even + # asked for, and --ota-platform means nothing to it. Every entry must + # be serial: a missing port is skipped and the next entry may be a host + devices = getattr(args, "device", None) or [] + serial_target = bool(devices) and all( + device == "SERIAL" + or get_port_type(device) in (PortType.SERIAL, PortType.BOOTSEL) + for device in devices + ) + chosen = ( + _choose_ota_platform(config, getattr(args, "ota_platform", None)) + if not serial_target and _ota_upload_platforms(config) + else None + ) + if chosen == CONF_WEB_SERVER: + raise EsphomeError(OTA_KEY_PLATFORM_ERROR) + from esphome.components.noise import validate_encryption_key + from esphome.config_validation import Invalid + + if serial_target: + _LOGGER.warning( + "The presented OTA key only applies to network uploads; ignored for %s", + ", ".join(devices), + ) + return + key = read_secret_line("OTA encryption key: ") if prompt else env_key + try: + validate_encryption_key(key) + except Invalid as err: + raise EsphomeError(f"Invalid OTA encryption key: {err}") from err + args.ota_key = key + + def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: + _read_ota_key(args, config) # Get devices, resolving special identifiers like OTA devices = choose_upload_log_host( default=args.device, @@ -1786,6 +1866,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: # Elsewhere the flag is ignored, so serial stays fine there. _LOGGER.error("--skip-bootloader builds cannot be flashed over serial") return 1 + _read_ota_key(args, config) exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -2216,6 +2297,15 @@ def _error_format(value: str) -> ErrorFormat: ) from None +ARGUMENT_HELP_PROMPT_OTA_KEY = ( + "Ask for the OTA encryption key to present to the device instead of using " + "the one in the configuration, for a device that still runs a previous key. " + "The key is read without echo, or as one line from stdin when that is not " + "a terminal; it is never taken from the command line. A calling program " + f"can hand the key over in the {ENV_OTA_KEY} environment variable instead." +) + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2384,6 +2474,11 @@ def parse_args(argv): help="Upload as partition table (OTA).", action="store_true", ) + parser_upload.add_argument( + "--prompt-ota-key", + action="store_true", + help=ARGUMENT_HELP_PROMPT_OTA_KEY, + ) parser_upload.add_argument( "--bootloader", help="Upload as bootloader (OTA).", @@ -2464,6 +2559,11 @@ def parse_args(argv): "(HTTP Basic auth) when that is the only configured platform." ), ) + parser_run.add_argument( + "--prompt-ota-key", + action="store_true", + help=ARGUMENT_HELP_PROMPT_OTA_KEY, + ) parser_clean = subparsers.add_parser( "clean-mqtt", @@ -2689,6 +2789,23 @@ def run_esphome(argv): args.log_level = "CRITICAL" setup_log(log_level=args.log_level) + # Taken out of the environment before any command runs, so no build + # tool inherits a key a calling program handed over + args.env_ota_key = os.environ.pop(ENV_OTA_KEY, None) + if args.command in ("upload", "run"): + # One key is one device's; several configs run in child processes + # that would never see it + if (args.env_ota_key is not None or args.prompt_ota_key) and len( + args.configuration + ) > 1: + raise EsphomeError( + f"--prompt-ota-key and {ENV_OTA_KEY} take one configuration; " + f"{len(args.configuration)} were given" + ) + elif args.env_ota_key is not None: + _LOGGER.warning( + "%s is set but the %s command does not use it", ENV_OTA_KEY, args.command + ) _warn_if_source_tree_mismatch() _warn_if_intel_macos() diff --git a/esphome/util.py b/esphome/util.py index dd1998a7c2..d090caeef1 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -130,6 +130,17 @@ def safe_input(prompt=""): return input() +def read_secret_line(prompt: str) -> str: + """Read a secret without echo on a terminal, or one line from a pipe.""" + if sys.stdin.isatty(): + import getpass + + return getpass.getpass(prompt).strip() + # Still say what is awaited, so a pipe held open is not a silent hang + print(prompt, end="", file=sys.stderr, flush=True) + return sys.stdin.readline().strip() + + def shlex_quote(s: str | Path) -> str: # Convert Path objects to strings if isinstance(s, Path): diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 3b8330f159..3b10c9cb3c 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,6 +25,7 @@ from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, + _read_ota_key, _redact_with_legacy_fallback, _resolve_network_devices, _should_subscribe_states, @@ -44,6 +45,7 @@ from esphome.__main__ import ( command_idedata, command_run, command_update_all, + command_upload, command_wizard, compile_program, detect_external_components, @@ -126,6 +128,7 @@ from esphome.espota2 import ( ) from esphome.platformio import toolchain from esphome.types import ConfigType +from esphome.upload_targets import PortType from esphome.util import BootselResult, FlashImage from esphome.zeroconf import _await_discovery, discover_mdns_devices @@ -1550,6 +1553,9 @@ class MockArgs: bootloader: bool = False states: bool | None = None device: list[str] | None = None + prompt_ota_key: bool = False + ota_key: str | None = None + env_ota_key: str | None = None def test_upload_program_serial_esp32( @@ -2223,6 +2229,384 @@ def test_upload_program_ota_allow_plaintext_upload( } +@pytest.mark.parametrize( + "ota_conf", + [ + { + CONF_ENCRYPTION: { + CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "allow_plaintext_upload": True, + } + }, + {}, + ], + ids=["with_encryption_block", "without_encryption_block"], +) +def test_upload_program_ota_prompted_key_is_presented( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, + ota_conf: dict[str, Any], +) -> None: + """A prompted key replaces whatever the config holds for this upload and + never downgrades, whatever the config allows.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + old_key = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + config = { + CONF_API: { + CONF_ENCRYPTION: {CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} + }, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, **ota_conf}], + } + exit_code, _ = upload_program(config, MockArgs(ota_key=old_key), ["192.168.1.100"]) + + assert exit_code == 0 + assert mock_run_ota.call_args.args[5] == old_key + assert mock_run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } + + +def test_upload_program_serial_ignores_prompted_key( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """A serial upload has no handshake to present the key to.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = PortType.SERIAL + mock_upload_using_esptool.return_value = 0 + + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}]} + with ( + patch("esphome.__main__.check_permissions"), + caplog.at_level(logging.WARNING), + ): + exit_code, _ = upload_program(config, MockArgs(ota_key="key"), ["/dev/ttyUSB0"]) + + assert exit_code == 0 + assert any("ignored for /dev/ttyUSB0" in r.message for r in caplog.records) + + +def test_command_run_prompts_for_ota_key_before_compile() -> None: + """The prompt comes first so it is not buried in minutes of build output.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, platform=PLATFORM_ESP32 + ) + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + calls: list[str] = [] + + def read(prompt: str) -> str: + calls.append("prompt") + return key + + def compile_program(*_: Any) -> int: + calls.append("compile") + assert args.ota_key == key + return 1 # stop before the upload + + with ( + patch("esphome.__main__.read_secret_line", side_effect=read), + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", side_effect=compile_program), + ): + assert command_run(args, CORE.config) == 1 + assert calls == ["prompt", "compile"] + + +def test_upload_program_platform_hook_ignores_prompted_key( + mock_get_port_type: Mock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """A platform's own network upload path (nrf52 mcumgr) has no key + handshake; the key is dropped with a warning, never silently.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232}]} + with ( + patch( + "esphome.__main__.platform_hooks.get_platform_hook", + return_value=lambda *_: True, + ), + caplog.at_level(logging.WARNING), + ): + exit_code, host = upload_program(config, MockArgs(ota_key="key"), ["dev.local"]) + + assert (exit_code, host) == (0, "dev.local") + assert any("ignored for dev.local" in r.message for r in caplog.records) + + +def test_command_upload_reads_the_ota_key_first() -> None: + """The upload command takes the key before choosing a device, like run.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, platform=PLATFORM_ESP32 + ) + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + args.device = None + with ( + patch("esphome.__main__.read_secret_line", return_value=key), + patch("esphome.__main__.choose_upload_log_host", return_value=["dev.local"]), + patch( + "esphome.__main__.upload_program", return_value=(0, "dev.local") + ) as upload, + ): + assert command_upload(args, CORE.config) == 0 + assert args.ota_key == key + upload.assert_called_once() + + +def test_read_ota_key_stores_prompted_key() -> None: + """The key lands on args and never in argv.""" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]} + with patch("esphome.__main__.read_secret_line", return_value=key) as read: + _read_ota_key(args, config) + read.assert_called_once_with("OTA encryption key: ") + assert args.ota_key == key + + +def test_read_ota_key_not_requested() -> None: + args = MockArgs() + with patch("esphome.__main__.read_secret_line") as read: + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + assert args.ota_key is None + + +def test_read_ota_key_from_environment() -> None: + """A calling program hands the key over in the environment.""" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(env_ota_key=key) + with patch("esphome.__main__.read_secret_line") as read: + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + assert args.ota_key == key + + +def test_read_ota_key_empty_environment_value_is_an_error() -> None: + """An exported empty value is a failed lookup upstream, not "unset".""" + args = MockArgs(env_ota_key="") + with pytest.raises(EsphomeError, match="Invalid OTA encryption key"): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + + +def test_read_ota_key_prompt_wins_over_environment() -> None: + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs( + prompt_ota_key=True, env_ota_key="AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" + ) + with patch("esphome.__main__.read_secret_line", return_value=key): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + assert args.ota_key == key + + +def test_read_ota_key_without_ota_block_is_left_to_the_upload() -> None: + """A serial flash config has no OTA; the key is ignored with a warning + at upload time instead of refusing here.""" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + with patch("esphome.__main__.read_secret_line", return_value=key): + _read_ota_key(args, {}) + assert args.ota_key == key + + +@pytest.mark.parametrize( + "argv", + [ + ["esphome", "run", "--prompt-ota-key", "a.yaml", "b.yaml"], + ["esphome", "upload", "a.yaml", "b.yaml"], + ], + ids=["prompt", "environment"], +) +def test_run_esphome_refuses_one_key_for_several_configs( + monkeypatch: pytest.MonkeyPatch, argv: list[str] +) -> None: + """Several configs run in child processes that never see the key.""" + from esphome.__main__ import run_esphome + + if "--prompt-ota-key" not in argv: + monkeypatch.setenv("ESPHOME_OTA_KEY", "x") + with pytest.raises(EsphomeError, match="take one configuration; 2 were given"): + run_esphome(argv) + + +def test_run_esphome_only_warns_for_a_command_with_one_string_config( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The wizard takes one config as a string; only upload and run count.""" + from esphome.__main__ import run_esphome + + monkeypatch.setenv("ESPHOME_OTA_KEY", "x") + wizard = Mock(return_value=0) + with ( + patch.dict("esphome.__main__.PRE_CONFIG_ACTIONS", {"wizard": wizard}), + caplog.at_level(logging.WARNING), + ): + assert run_esphome(["esphome", "wizard", "living-room.yaml"]) == 0 + assert any("does not use it" in r.message for r in caplog.records) + + +def test_run_esphome_scrubs_the_key_from_the_environment( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Every command takes the key out before any tool it runs could see it.""" + from esphome.__main__ import run_esphome + + monkeypatch.setenv("ESPHOME_OTA_KEY", "x") + version = Mock(return_value=0) + with ( + patch.dict("esphome.__main__.PRE_CONFIG_ACTIONS", {"version": version}), + caplog.at_level(logging.WARNING), + ): + run_esphome(["esphome", "version"]) + assert "ESPHOME_OTA_KEY" not in os.environ + assert version.call_args.args[0].env_ota_key == "x" + # A command that never uploads says so rather than dropping it silently + assert any("does not use it" in r.message for r in caplog.records) + + +@pytest.mark.parametrize("line", ["", "not-base64"], ids=["empty", "invalid"]) +def test_read_ota_key_rejects_bad_input(line: str) -> None: + args = MockArgs(prompt_ota_key=True) + config = {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]} + with ( + patch("esphome.__main__.read_secret_line", return_value=line), + pytest.raises(EsphomeError, match="Invalid OTA encryption key"), + ): + _read_ota_key(args, config) + assert args.ota_key is None + + +@pytest.mark.parametrize( + ("device", "port_type", "ota_platform"), + [ + ("/dev/ttyUSB0", PortType.SERIAL, None), + ("/dev/ttyUSB0", PortType.SERIAL, CONF_WEB_SERVER), + ("SERIAL", PortType.NETWORK, None), + ], + ids=["port", "port_with_ota_platform", "selector"], +) +def test_read_ota_key_lets_a_serial_target_through( + mock_get_port_type: Mock, + caplog: pytest.LogCaptureFixture, + device: str, + port_type: PortType, + ota_platform: str | None, +) -> None: + """A serial flash of a web_server only config is not refused, whatever + --ota-platform says; the key is not asked for and the user is told it is + ignored. The SERIAL selector only resolves to a port later, so it counts + by name.""" + mock_get_port_type.return_value = port_type + args = MockArgs(prompt_ota_key=True, ota_platform=ota_platform) + args.device = [device] + with ( + patch("esphome.__main__.read_secret_line") as read, + caplog.at_level(logging.WARNING), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}]}) + read.assert_not_called() + assert args.ota_key is None + assert any(f"ignored for {device}" in r.message for r in caplog.records) + + +def test_read_ota_key_serial_then_network_still_asks( + mock_get_port_type: Mock, +) -> None: + """`--device SERIAL --device OTA` with no port attached ends up on the + network, so the key is read and a web_server only config is refused.""" + mock_get_port_type.side_effect = lambda d: ( + PortType.SERIAL if d.startswith("/dev") else PortType.NETWORK + ) + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + args = MockArgs(prompt_ota_key=True) + args.device = ["/dev/ttyUSB0", "OTA"] + with patch("esphome.__main__.read_secret_line", return_value=key) as read: + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_called_once() + assert args.ota_key == key + args = MockArgs(prompt_ota_key=True) + args.device = ["SERIAL", "OTA"] + with ( + patch("esphome.__main__.read_secret_line") as read, + pytest.raises(EsphomeError, match="only apply to the esphome OTA platform"), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}]}) + read.assert_not_called() + + +def test_read_ota_key_ignored_for_a_host_build( + caplog: pytest.LogCaptureFixture, +) -> None: + """A host run never uploads, so nothing is asked for.""" + setup_core(platform=PLATFORM_HOST) + args = MockArgs(prompt_ota_key=True) + with ( + patch("esphome.__main__.read_secret_line") as read, + caplog.at_level(logging.WARNING), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + assert args.ota_key is None + assert any("host program" in r.message for r in caplog.records) + + +def test_read_ota_key_reports_a_bad_platform_before_prompting() -> None: + args = MockArgs(prompt_ota_key=True, ota_platform=CONF_WEB_SERVER) + with ( + patch("esphome.__main__.read_secret_line") as read, + pytest.raises(EsphomeError, match="only provides"), + ): + _read_ota_key(args, {CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}) + read.assert_not_called() + + +@pytest.mark.parametrize( + ("ota_platform", "config"), + [ + ( + CONF_WEB_SERVER, + { + CONF_OTA: [ + {CONF_PLATFORM: CONF_ESPHOME}, + {CONF_PLATFORM: CONF_WEB_SERVER}, + ] + }, + ), + (None, {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}]}), + ], + ids=["requested", "only_platform"], +) +@pytest.mark.parametrize("devices", [["192.168.1.100"], None], ids=["host", "chooser"]) +def test_read_ota_key_refuses_web_server_platform( + mock_get_port_type: Mock, + ota_platform: str | None, + config: dict[str, Any], + devices: list[str] | None, +) -> None: + """The HTTP path has no key handshake, so the flag is refused before the + prompt and before any compile, whether web_server was asked for or is the + only platform in the config, for a network target or none named yet.""" + mock_get_port_type.return_value = PortType.NETWORK + args = MockArgs(prompt_ota_key=True, ota_platform=ota_platform) + args.device = devices + with ( + patch("esphome.__main__.read_secret_line") as read, + pytest.raises(EsphomeError, match="only apply to the esphome OTA"), + ): + _read_ota_key(args, config) + read.assert_not_called() + + def test_upload_program_ota_api_key_opportunistic( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -2889,6 +3273,22 @@ def test_upload_program_web_server_only_auto_dispatches( mock_run_ota.assert_not_called() +def test_upload_program_web_server_refuses_a_presented_key( + mock_run_web_server_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """A network target picked in the chooser reaches the HTTP path with the + key; it is refused rather than silently dropped.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + config = {CONF_OTA: [{CONF_PLATFORM: CONF_WEB_SERVER}], CONF_WEB_SERVER: {}} + args = MockArgs(ota_key="AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=") + with pytest.raises(EsphomeError, match="only apply to the esphome OTA"): + upload_program(config, args, ["192.168.1.100"]) + mock_run_web_server_ota.assert_not_called() + + def test_upload_program_web_server_no_auth( mock_run_web_server_ota: Mock, mock_get_port_type: Mock, @@ -6445,6 +6845,17 @@ def test_parse_args_run_states() -> None: assert args.states is True +@pytest.mark.parametrize("command", ["upload", "run"]) +def test_parse_args_prompt_ota_key(command: str) -> None: + """The flag takes no value: the key is never accepted on the command line.""" + args = parse_args(["esphome", command, "--prompt-ota-key", "device.yaml"]) + assert args.prompt_ota_key is True + args = parse_args(["esphome", command, "device.yaml"]) + assert args.prompt_ota_key is False + with pytest.raises(SystemExit): + parse_args(["esphome", command, "--prompt-ota-key=abc", "device.yaml"]) + + def test_parse_args_run_states_default() -> None: """Test that states defaults to None (unset) for the run command.""" args = parse_args(["esphome", "run", "device.yaml"]) diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index a4b091b7c2..739a0e2ada 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -1177,3 +1177,26 @@ class TestSafePrint: assert ( capsys.readouterr().out == "Cannot print line because of invalid locale!\n" ) + + +@pytest.mark.parametrize("is_tty", [True, False], ids=["tty", "pipe"]) +def test_read_secret_line(is_tty: bool) -> None: + """A terminal reads without echo, a pipe reads one line; both strip.""" + with ( + patch("esphome.util.sys.stdin") as stdin, + patch("getpass.getpass", return_value=" secret \n") as getpass, + ): + stdin.isatty.return_value = is_tty + stdin.readline.return_value = " secret \r\n" + assert util.read_secret_line("Key: ") == "secret" + assert getpass.called is is_tty + assert stdin.readline.called is not is_tty + + +def test_read_secret_line_empty_pipe(capsys: pytest.CaptureFixture[str]) -> None: + """The prompt still goes to stderr on a pipe so a blocked read is diagnosable.""" + with patch("esphome.util.sys.stdin") as stdin: + stdin.isatty.return_value = False + stdin.readline.return_value = "" + assert util.read_secret_line("Key: ") == "" + assert capsys.readouterr().err == "Key: " From dc719b300b20b07fecebb295327cdbd24cec9036 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:16:29 -1000 Subject: [PATCH 190/268] [udp] Use the socket UDP API on ESP8266 and RP2040 (#20558) --- esphome/components/socket/socket.cpp | 9 +- esphome/components/socket/socket.h | 6 +- esphome/components/udp/automation.h | 2 +- esphome/components/udp/udp_component.cpp | 90 +++++++------------ esphome/components/udp/udp_component.h | 25 ++---- .../fixtures/udp_multicast_join.yaml | 22 +++++ tests/integration/test_udp_multicast.py | 75 ++++++++++++++++ 7 files changed, 149 insertions(+), 80 deletions(-) create mode 100644 tests/integration/fixtures/udp_multicast_join.yaml create mode 100644 tests/integration/test_udp_multicast.py diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index a91c455e0f..36cd6d6ab7 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -218,8 +218,9 @@ template static bool foreach_eligible_ipv6_if(F &&callback) { return found_any; #endif } +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS -bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out) { +bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_index_out) { if (strchr(ip_address, ':') == nullptr) { struct in_addr ipv4mc {}; #ifdef USE_ZEPHYR @@ -249,7 +250,8 @@ bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_ind } return true; } -#if USE_NETWORK_IPV6 + // The raw lwIP implementation has no IPv6 membership support +#if USE_NETWORK_IPV6 && (defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)) struct ipv6_mreq imreq6 {}; #ifdef USE_SOCKET_IMPL_BSD_SOCKETS #ifdef USE_ZEPHYR @@ -300,9 +302,10 @@ bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_ind #else errno = EINVAL; return false; -#endif // USE_NETWORK_IPV6 +#endif // USE_NETWORK_IPV6 && fd based sockets } +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #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) diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 8f5860e26e..7310e32afb 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -191,16 +191,16 @@ 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 ip_address Null-terminated multicast address string (IPv4, or IPv6 except on LWIP_TCP) /// @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); +bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_index_out = nullptr); +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #if USE_NETWORK_IPV6 /// Configure the outgoing IPv6 multicast interface on the given socket. /// @param sock The socket to configure diff --git a/esphome/components/udp/automation.h b/esphome/components/udp/automation.h index a6a40829b1..0936aa3ab5 100644 --- a/esphome/components/udp/automation.h +++ b/esphome/components/udp/automation.h @@ -13,7 +13,7 @@ template class UDPWriteAction final : public Action, publ TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - // One write per packet: WiFiUDP appends all or nothing, so a failed allocation never sends a partial packet + // One sendto per packet, so a failed allocation never sends a partial packet this->data_.template visit( [this](const uint8_t *data, size_t len) { this->parent_->send_packet(data, len); }, x...); } diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 3918ddc1fa..8e82403834 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -8,9 +8,10 @@ namespace esphome::udp { ESPHOME_LOG_TAG(TAG, "udp"); +static constexpr uint32_t JOIN_POLL_ID = 0; +static constexpr uint32_t JOIN_POLL_INTERVAL_MS = 1000; void UDPComponent::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { const char *address = *it; struct sockaddr saddr {}; @@ -24,7 +25,7 @@ void UDPComponent::setup() { } // set up broadcast socket if (this->should_broadcast_) { - this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -44,7 +45,7 @@ void UDPComponent::setup() { // create listening socket if we either want to subscribe to providers, or need to listen // for ping key broadcasts. if (this->should_listen_) { - this->listen_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + this->listen_socket_ = socket::socket_udp(AF_INET, IPPROTO_IP); if (this->listen_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -69,22 +70,13 @@ void UDPComponent::setup() { server.sin_addr.s_addr = ESPHOME_INADDR_ANY; server.sin_port = htons(this->listen_port_); - if (this->listen_address_.has_value()) { - // Only 16 bytes needed for IPv4, but use standard size for consistency - char addr_buf[network::IP_ADDRESS_BUFFER_SIZE]; - this->listen_address_.value().str_to(addr_buf); - struct ip_mreq imreq = {}; - imreq.imr_interface.s_addr = ESPHOME_INADDR_ANY; - inet_aton(addr_buf, &imreq.imr_multiaddr); - server.sin_addr.s_addr = imreq.imr_multiaddr.s_addr; - ESP_LOGD(TAG, "Join multicast %s", addr_buf); - err = this->listen_socket_->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)); - if (err < 0) { - ESP_LOGE(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); - this->status_set_error(LOG_STR("Failed to set IP_ADD_MEMBERSHIP")); - this->mark_failed(); - return; - } + if (this->listen_address_ != nullptr) { + inet_aton(this->listen_address_, &server.sin_addr); + // Joined once the network is up; RP2040 has no interface to join before then + this->set_interval(JOIN_POLL_ID, JOIN_POLL_INTERVAL_MS, [this]() { + if (network::is_connected() && this->join_multicast_()) + this->cancel_interval(JOIN_POLL_ID); + }); } err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server)); @@ -95,36 +87,35 @@ void UDPComponent::setup() { return; } } -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - // 8266 and RP2040 `Duino - for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { - const char *address = *it; - auto ipaddr = IPAddress(); - if (!ipaddr.fromString(address)) { - ESP_LOGW(TAG, "Invalid address %s", address); - this->status_set_warning(LOG_STR("invalid address")); - continue; +} + +bool UDPComponent::join_multicast_() { + if (socket::join_multicast_group(this->listen_socket_.get(), this->listen_address_)) { + ESP_LOGD(TAG, "Joined multicast %s", this->listen_address_); + if (this->join_warning_) { + this->join_warning_ = false; + this->status_clear_warning(); } - this->ipaddrs_.push_back(ipaddr); + return true; } - if (this->should_listen_) - this->udp_client_.begin(this->listen_port_); -#endif + // Log the first failure only; the poll keeps retrying every second + if (!this->join_failed_) { + this->join_failed_ = true; + ESP_LOGW(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); + // One warning bit: only a warning raised here is cleared on success, one from setup() stays + if (!this->status_has_warning()) { + this->status_set_warning(LOG_STR("Failed to set IP_ADD_MEMBERSHIP")); + this->join_warning_ = true; + } + } + return false; } void UDPComponent::loop() { if (this->should_listen_) { std::array buf; for (;;) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) auto len = this->listen_socket_->read(buf.data(), buf.size()); -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - auto len = this->udp_client_.parsePacket(); - if (len > 0) - len = this->udp_client_.read(buf.data(), buf.size()); -#endif if (len <= 0) break; size_t packet_len = static_cast(len); @@ -144,9 +135,8 @@ void UDPComponent::dump_config() { const char *address = *it; ESP_LOGCONFIG(TAG, " Address: %s", address); } - if (this->listen_address_.has_value()) { - char addr_buf[network::IP_ADDRESS_BUFFER_SIZE]; - ESP_LOGCONFIG(TAG, " Listen address: %s", this->listen_address_.value().str_to(addr_buf)); + if (this->listen_address_ != nullptr) { + ESP_LOGCONFIG(TAG, " Listen address: %s", this->listen_address_); } ESP_LOGCONFIG(TAG, " Broadcasting: %s\n" @@ -155,26 +145,12 @@ void UDPComponent::dump_config() { } void UDPComponent::send_packet(const uint8_t *data, size_t size) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) for (const auto &saddr : this->sockaddrs_) { auto result = this->broadcast_socket_->sendto(data, size, 0, &saddr, sizeof(saddr)); if (result < 0) { ESP_LOGW(TAG, "sendto() error %d", errno); } } -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - auto iface = IPAddress(0, 0, 0, 0); - for (const auto &saddr : this->ipaddrs_) { - if (this->udp_client_.beginPacketMulticast(saddr, this->broadcast_port_, iface, 128) != 0) { - this->udp_client_.write(data, size); - auto result = this->udp_client_.endPacket(); - if (result == 0) { - ESP_LOGW(TAG, "udp.write() error"); - } - } - } -#endif } } // namespace esphome::udp diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 17e27fb770..276fd5adf1 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -3,13 +3,7 @@ #include "esphome/core/defines.h" #ifdef USE_NETWORK #include "esphome/core/helpers.h" -#include "esphome/components/network/ip_address.h" -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP -#include -#endif #include #include #include @@ -22,7 +16,8 @@ class UDPComponent final : public Component { public: /// nullptr-terminated table of addresses; must outlive the component. void set_addresses(const char *const *addresses) { this->addresses_ = addresses; } - void set_listen_address(const char *listen_addr) { this->listen_address_ = network::IPAddress(listen_addr); } + /// Multicast group literal; must outlive the component. + void set_listen_address(const char *listen_addr) { this->listen_address_ = listen_addr; } void set_listen_port(uint16_t port) { this->listen_port_ = port; } void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } void set_should_broadcast() { this->should_broadcast_ = true; } @@ -36,24 +31,22 @@ class UDPComponent final : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }; protected: + bool join_multicast_(); + uint16_t listen_port_{}; uint16_t broadcast_port_{}; bool should_broadcast_{}; bool should_listen_{}; + bool join_failed_{}; + bool join_warning_{}; CallbackManager)> packet_listeners_{}; -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr broadcast_socket_ = nullptr; - std::unique_ptr listen_socket_ = nullptr; + std::unique_ptr broadcast_socket_ = nullptr; + std::unique_ptr listen_socket_ = nullptr; std::vector sockaddrs_{}; -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - std::vector ipaddrs_{}; - WiFiUDP udp_client_{}; -#endif const char *const *addresses_{nullptr}; - optional listen_address_{}; + const char *listen_address_{nullptr}; }; } // namespace esphome::udp diff --git a/tests/integration/fixtures/udp_multicast_join.yaml b/tests/integration/fixtures/udp_multicast_join.yaml new file mode 100644 index 0000000000..e2c6c350bf --- /dev/null +++ b/tests/integration/fixtures/udp_multicast_join.yaml @@ -0,0 +1,22 @@ +esphome: + name: udp-multicast-test + +host: + +api: + +logger: + level: DEBUG + +udp: + - id: test_udp + listen_address: 239.1.2.3 + addresses: + - "239.1.2.3" + port: + listen_port: UDP_LISTEN_PORT_PLACEHOLDER + broadcast_port: UDP_LISTEN_PORT_PLACEHOLDER + on_receive: + - logger.log: + format: "Received UDP: %d bytes" + args: [data.size()] diff --git a/tests/integration/test_udp_multicast.py b/tests/integration/test_udp_multicast.py new file mode 100644 index 0000000000..98a9059142 --- /dev/null +++ b/tests/integration/test_udp_multicast.py @@ -0,0 +1,75 @@ +"""Integration test for the UDP component's deferred multicast join.""" + +from __future__ import annotations + +import asyncio +import socket + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +MULTICAST_GROUP = "239.1.2.3" + + +def _get_free_udp_port() -> int: + """Get a free UDP port by binding to port 0 and releasing.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +@pytest.mark.asyncio +async def test_udp_multicast_join( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The group is joined from the poll after setup, once, and then receives.""" + log_lines: list[str] = [] + joined_event = asyncio.Event() + receive_event = asyncio.Event() + + def on_log_line(line: str) -> None: + log_lines.append(line) + if f"Joined multicast {MULTICAST_GROUP}" in line: + joined_event.set() + if "Received UDP:" in line: + receive_event.set() + + listen_port = _get_free_udp_port() + config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)) + + async with ( + run_compiled(config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "udp-multicast-test" + + try: + await asyncio.wait_for(joined_event.wait(), timeout=5.0) + except TimeoutError: + pytest.fail(f"Multicast group was not joined. Log: {log_lines[-20:]}") + + # The poll cancels itself after the join, so a second poll period must not join again + await asyncio.sleep(2.5) + join_lines = [line for line in log_lines if "Joined multicast" in line] + assert len(join_lines) == 1, f"Expected one join, got: {join_lines}" + assert not any("IP_ADD_MEMBERSHIP" in line for line in log_lines), ( + f"Join reported a failure. Log: {log_lines[-20:]}" + ) + + send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + send_sock.sendto(b"MULTICAST_PAYLOAD", (MULTICAST_GROUP, listen_port)) + finally: + send_sock.close() + + try: + await asyncio.wait_for(receive_event.wait(), timeout=5.0) + except TimeoutError: + pytest.fail(f"Multicast datagram was not received. Log: {log_lines[-20:]}") From 4c549d6e92c1ec52096e2c206796a768bc08d376 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:16:47 -1000 Subject: [PATCH 191/268] [e131] Use the socket UDP API on ESP8266 and RP2040 (#20559) --- esphome/components/e131/__init__.py | 18 +++++--- esphome/components/e131/e131.cpp | 24 +++++------ esphome/components/e131/e131.h | 25 ++--------- esphome/components/e131/e131_packet.cpp | 57 ++++++++++++++----------- 4 files changed, 59 insertions(+), 65 deletions(-) diff --git a/esphome/components/e131/__init__.py b/esphome/components/e131/__init__.py index dbe9b1e45c..2d48f5a593 100644 --- a/esphome/components/e131/__init__.py +++ b/esphome/components/e131/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components import socket from esphome.components.light.effects import register_addressable_effect from esphome.components.light.types import AddressableLightEffect import esphome.config_validation as cv @@ -28,12 +29,17 @@ CHANNELS = { CONF_UNIVERSE = "universe" CONF_E131_ID = "e131_id" -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(E131Component), - cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of(*METHODS, upper=True), - } -).extend(cv.COMPONENT_SCHEMA) +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(E131Component), + cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of( + *METHODS, upper=True + ), + } + ).extend(cv.COMPONENT_SCHEMA), + socket.consume_sockets(1, "e131", socket.SocketType.UDP), +) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/e131/e131.cpp b/esphome/components/e131/e131.cpp index 09c854c649..1d847963fe 100644 --- a/esphome/components/e131/e131.cpp +++ b/esphome/components/e131/e131.cpp @@ -1,6 +1,7 @@ #include "e131.h" #ifdef USE_NETWORK #include "e131_addressable_light_effect.h" +#include "esphome/components/network/util.h" #include "esphome/core/log.h" #include @@ -13,18 +14,18 @@ static const int PORT = 5568; E131Component::E131Component() {} E131Component::~E131Component() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) if (this->socket_) { this->socket_->close(); } -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - this->udp_.stop(); -#endif } void E131Component::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - this->socket_ = socket::socket_ip(SOCK_DGRAM, IPPROTO_IP); + this->socket_ = socket::socket_ip_udp(IPPROTO_IP); + if (!this->socket_) { + ESP_LOGW(TAG, "Could not create socket: errno %d", errno); + this->mark_failed(); + return; + } int enable = 1; int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); @@ -54,18 +55,13 @@ void E131Component::setup() { this->mark_failed(); return; } -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - if (!this->udp_.begin(PORT)) { - ESP_LOGW(TAG, "Cannot bind E1.31 to port %d.", PORT); - this->mark_failed(); - return; - } -#endif join_igmp_groups_(); } void E131Component::loop() { + if (this->igmp_join_pending_ && network::is_connected()) + this->join_igmp_groups_(); E131Packet packet; int universe = 0; uint8_t buf[1460]; @@ -74,7 +70,7 @@ void E131Component::loop() { // Drain all queued packets so multi-universe frames are applied // atomically before the light writes. Without this, each universe // packet would trigger a separate full-strip write causing tearing. - while ((len = this->read_(buf, sizeof(buf))) > 0) { + while ((len = this->socket_->read(buf, sizeof(buf))) > 0) { if (!this->packet_(buf, (size_t) len, universe, packet)) { ESP_LOGV(TAG, "Invalid packet received of size %d.", (int) len); continue; diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index b0a8b4f83f..7d6983ddc3 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -1,11 +1,7 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_NETWORK -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) -#include -#endif #include "esphome/core/component.h" #include @@ -45,30 +41,17 @@ class E131Component final : public esphome::Component { void set_method(E131ListenMethod listen_method) { this->listen_method_ = listen_method; } protected: - inline ssize_t read_(uint8_t *buf, size_t len) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - return this->socket_->read(buf, len); -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - if (!this->udp_.parsePacket()) - return -1; - return this->udp_.read(buf, len); -#else - return -1; -#endif - } bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet); bool process_(int universe, const E131Packet &packet); - bool join_igmp_groups_(); + void join_igmp_groups_(); + void join_universe_(uint16_t universe); UniverseConsumer *find_universe_(int universe); void join_(int universe); void leave_(int universe); E131ListenMethod listen_method_{E131_MULTICAST}; -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr socket_; -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - WiFiUDP udp_; -#endif + bool igmp_join_pending_{false}; + std::unique_ptr socket_; std::vector light_effects_; std::vector universe_consumers_; }; diff --git a/esphome/components/e131/e131_packet.cpp b/esphome/components/e131/e131_packet.cpp index e3f2e3940a..14e3b64133 100644 --- a/esphome/components/e131/e131_packet.cpp +++ b/esphome/components/e131/e131_packet.cpp @@ -3,6 +3,7 @@ #include "e131.h" #ifdef USE_NETWORK #include "esphome/components/network/ip_address.h" +#include "esphome/components/network/util.h" #include "esphome/core/log.h" #include "esphome/core/util.h" #include "esphome/core/helpers.h" @@ -59,33 +60,35 @@ union E131RawPacket { // Get the offset of `property_values[1]` const size_t E131_MIN_PACKET_SIZE = offsetof(E131RawPacket, property_values) + sizeof(uint8_t); -bool E131Component::join_igmp_groups_() { - if (this->listen_method_ != E131_MULTICAST) - return false; -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - if (this->socket_ == nullptr) - return false; -#endif - +void E131Component::join_igmp_groups_() { + if (this->listen_method_ != E131_MULTICAST || this->socket_ == nullptr) + return; + // Joined from loop() once the network is up; RP2040 has no interface to join before then + if (!network::is_connected()) { + this->igmp_join_pending_ = true; + return; + } + this->igmp_join_pending_ = false; for (auto &entry : this->universe_consumers_) { - if (!entry.consumers) - continue; + if (entry.consumers) + this->join_universe_(entry.universe); + } +} - ip4_addr_t multicast_addr = - network::IPAddress(239, 255, ((entry.universe >> 8) & 0xff), ((entry.universe >> 0) & 0xff)); +void E131Component::join_universe_(uint16_t universe) { + ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff)); - err_t err; - { - LwIPLock lock; - err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr); - } - - if (err) { - ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", entry.universe); - } + err_t err; + { + LwIPLock lock; + err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr); } - return true; + if (err) { + ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", universe); + } else { + ESP_LOGD(TAG, "Joined %d universe for E1.31.", universe); + } } UniverseConsumer *E131Component::find_universe_(int universe) { @@ -107,9 +110,15 @@ void E131Component::join_(int universe) { this->universe_consumers_.push_back({static_cast(universe), 1}); } - if (this->join_igmp_groups_()) { - ESP_LOGD(TAG, "Joined %d universe for E1.31.", universe); + if (this->listen_method_ != E131_MULTICAST || this->socket_ == nullptr) + return; + // A pending batch join covers this universe too; joining it here as well would break leave_() + if (this->igmp_join_pending_ || !network::is_connected()) { + ESP_LOGD(TAG, "IGMP join for %d universe of E1.31 deferred until the network is up.", universe); + this->igmp_join_pending_ = true; + return; } + this->join_universe_(universe); } void E131Component::leave_(int universe) { From 5bb1f0f779b0d3068af05d2301d21d6a0806e146 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:17:01 -1000 Subject: [PATCH 192/268] [statsd] Use the socket UDP API on ESP8266 and RP2040 (#20557) --- esphome/components/statsd/__init__.py | 27 +++++++++++-------- esphome/components/statsd/statsd.cpp | 38 +++++---------------------- esphome/components/statsd/statsd.h | 15 ++--------- 3 files changed, 24 insertions(+), 56 deletions(-) diff --git a/esphome/components/statsd/__init__.py b/esphome/components/statsd/__init__.py index 16df864082..0a0c4a59fb 100644 --- a/esphome/components/statsd/__init__.py +++ b/esphome/components/statsd/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor +from esphome.components import binary_sensor, sensor, socket from esphome.components.const import CONF_HOST import esphome.config_validation as cv from esphome.const import ( @@ -35,16 +35,21 @@ CONFIG_BINARY_SENSORS_SCHEMA = cv.Schema( } ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(StatsdComponent), - cv.Required(CONF_HOST): cv.string_strict, - cv.Optional(CONF_PORT, default=8125): cv.port, - cv.Optional(CONF_PREFIX, default=""): cv.string_strict, - cv.Optional(CONF_SENSORS): cv.ensure_list(CONFIG_SENSORS_SCHEMA), - cv.Optional(CONF_BINARY_SENSORS): cv.ensure_list(CONFIG_BINARY_SENSORS_SCHEMA), - } -).extend(cv.polling_component_schema("10s")) +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(StatsdComponent), + cv.Required(CONF_HOST): cv.string_strict, + cv.Optional(CONF_PORT, default=8125): cv.port, + cv.Optional(CONF_PREFIX, default=""): cv.string_strict, + cv.Optional(CONF_SENSORS): cv.ensure_list(CONFIG_SENSORS_SCHEMA), + cv.Optional(CONF_BINARY_SENSORS): cv.ensure_list( + CONFIG_BINARY_SENSORS_SCHEMA + ), + } + ).extend(cv.polling_component_schema("10s")), + socket.consume_sockets(1, "statsd", socket.SocketType.UDP), +) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 12af8345f1..1c9474bdb7 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -13,28 +13,15 @@ static const uint16_t SEND_THRESHOLD = 1024; ESPHOME_LOG_TAG(TAG, "statsD"); void StatsdComponent::setup() { -#ifndef USE_ESP8266 - this->sock_ = esphome::socket::socket(AF_INET, SOCK_DGRAM, 0); - - struct sockaddr_in source; - source.sin_family = AF_INET; - source.sin_addr.s_addr = htonl(INADDR_ANY); - source.sin_port = htons(this->port_); - this->sock_->bind((struct sockaddr *) &source, sizeof(source)); + this->sock_ = esphome::socket::socket_udp_send(AF_INET, 0); + if (!this->sock_) { + this->mark_failed(LOG_STR("Could not create socket")); + return; + } this->destination_.sin_family = AF_INET; this->destination_.sin_port = htons(this->port_); this->destination_.sin_addr.s_addr = inet_addr(this->host_); -#endif -} - -StatsdComponent::~StatsdComponent() { -#ifndef USE_ESP8266 - if (!this->sock_) { - return; - } - this->sock_->close(); -#endif } void StatsdComponent::dump_config() { @@ -141,19 +128,7 @@ void StatsdComponent::update() { } void StatsdComponent::send_(std::string *out) { - if (out->empty()) { - return; - } -#ifdef USE_ESP8266 - IPAddress ip; - ip.fromString(this->host_); - - this->sock_.beginPacket(ip, this->port_); - this->sock_.write((const uint8_t *) out->c_str(), out->length()); - this->sock_.endPacket(); - -#else - if (!this->sock_) { + if (out->empty() || !this->sock_) { return; } @@ -162,7 +137,6 @@ void StatsdComponent::send_(std::string *out) { if (n_bytes != static_cast(out->length())) { ESP_LOGE(TAG, "Failed to send UDP packed (%d of %d)", n_bytes, out->length()); } -#endif } } // namespace esphome::statsd diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 7cbde6d743..a12cc65810 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -20,17 +20,10 @@ #include "esphome/components/logger/logger.h" #endif -#ifdef USE_ESP8266 -#include "WiFiUdp.h" -#include "IPAddress.h" -#endif - namespace esphome::statsd { class StatsdComponent final : public PollingComponent { public: - ~StatsdComponent(); - void setup() override; void dump_config() override; void update() override; @@ -71,12 +64,8 @@ class StatsdComponent final : public PollingComponent { std::vector sensors_; -#ifdef USE_ESP8266 - WiFiUDP sock_; -#else - std::unique_ptr sock_; - struct sockaddr_in destination_; -#endif + std::unique_ptr sock_; + struct sockaddr_in destination_ {}; void send_(std::string *out); }; From db9138c5509b37b17a1097f559b17242fde396e7 Mon Sep 17 00:00:00 2001 From: Andrei Mateescu Date: Mon, 12 Oct 2026 20:34:25 +0300 Subject: [PATCH 193/268] [bl0939] Add current transformer measurement mode support (#13350) Co-authored-by: ShuishengPeng <63339353+ackPeng@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/bl0939/bl0939.cpp | 6 ++- esphome/components/bl0939/bl0939.h | 37 +++++++++++++++++-- esphome/components/bl0939/sensor.py | 15 ++++++++ .../components/bl0939/test-ct.esp32-idf.yaml | 26 +++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 tests/components/bl0939/test-ct.esp32-idf.yaml diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index 3a34fad140..e8dc09abba 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -129,7 +129,11 @@ void BL0939::received_package_(const DataPacket *data) const { } void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexity) - ESP_LOGCONFIG(TAG, "BL0939:"); + ESP_LOGCONFIG(TAG, + "BL0939:\n" + " Mode: %s", + this->mode_ == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER ? LOG_STR_LITERAL("current_transformer") + : LOG_STR_LITERAL("direct")); LOG_SENSOR("", "Voltage", this->voltage_sensor_); LOG_SENSOR("", "Current 1", this->current_sensor_1_); LOG_SENSOR("", "Current 2", this->current_sensor_2_); diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index a22d96ad40..257d9459b5 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -8,13 +8,32 @@ namespace esphome::bl0939 { // https://datasheet.lcsc.com/lcsc/2108071830_BL-Shanghai-Belling-BL0939_C2841044.pdf // (unfortunately chinese, but the formulas can be easily understood) +// The datasheet has one set of formulas for direct mode (current shunt) and one for current transformer mode. + +// Direct mode. // Sonoff Dual R3 V2 has the exact same resistor values for the current shunts (RL=1miliOhm) // and for the voltage divider (R1=0.51kOhm, R2=5*390kOhm) // as in the manufacturer's reference circuit, so the same formulas were used here (Vref=1.218V) -static const float BL0939_IREF = 324004 * 1 / 1.218; -static const float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51)); -static const float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51)); -static const float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51)); +static constexpr float BL0939_IREF = 324004 * 1 / 1.218; +static constexpr float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51)); +static constexpr float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51)); +static constexpr float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51)); + +// Current transformer mode. +// Values from the Seeed Studio XIAO 2-Channel Wi-Fi AC Energy Meter V1.0 schematic (Vref=1.218V). +// Current: 100A:50mA CT (2000:1) into a 0.6Ohm burden (2 * 0.3Ohm), so 0.3mV per primary amp. +// That is the same as a 0.3mOhm shunt in the direct mode formula. +// Voltage: ZMPT107-1 2mA:2mA voltage transformer with R2=5*22kOhm on the primary and R1=24.9Ohm on +// the secondary. The transformer isolates R1 from R2, so the divider is R1/R2 instead of R1/(R1+R2). +static constexpr float BL0939_IREF_CT = 324004 * 0.3 / 1.218; +static constexpr float BL0939_UREF_CT = 79931 * 24.9 * 1000 / (1.218 * 5 * 22000); +static constexpr float BL0939_PREF_CT = 4046 * 0.3 * 24.9 * 1000 / (1.218 * 1.218 * 5 * 22000); +static constexpr float BL0939_EREF_CT = 3.6e6 * 4046 * 0.3 * 24.9 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * 5 * 22000); + +enum class BL0939Mode : uint8_t { + BL0939_MODE_DIRECT = 0, + BL0939_MODE_CURRENT_TRANSFORMER = 1, +}; struct ube24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align) uint8_t l; @@ -67,6 +86,15 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { void set_energy_sensor_2(sensor::Sensor *energy_sensor_2) { energy_sensor_2_ = energy_sensor_2; } void set_energy_sensor_sum(sensor::Sensor *energy_sensor_sum) { energy_sensor_sum_ = energy_sensor_sum; } void set_internal_temperature_sensor(sensor::Sensor *sensor) { this->internal_temperature_sensor_ = sensor; } + // Inline so a constant mode from codegen folds down to plain stores. + void set_mode(BL0939Mode mode) { + this->mode_ = mode; + const bool current_transformer = mode == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER; + this->power_reference_ = current_transformer ? BL0939_PREF_CT : BL0939_PREF; + this->voltage_reference_ = current_transformer ? BL0939_UREF_CT : BL0939_UREF; + this->current_reference_ = current_transformer ? BL0939_IREF_CT : BL0939_IREF; + this->energy_reference_ = current_transformer ? BL0939_EREF_CT : BL0939_EREF; + } void loop() override; @@ -95,6 +123,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { float current_reference_ = BL0939_IREF; // Divide by this to turn into kWh float energy_reference_ = BL0939_EREF; + BL0939Mode mode_{BL0939Mode::BL0939_MODE_DIRECT}; static uint32_t to_uint32_t(ube24_t input); diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 401fafedd5..248002d185 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -4,6 +4,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INTERNAL_TEMPERATURE, + CONF_MODE, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -33,11 +34,22 @@ CONF_ENERGY_TOTAL = "energy_total" bl0939_ns = cg.esphome_ns.namespace("bl0939") BL0939 = bl0939_ns.class_("BL0939", cg.PollingComponent, uart.UARTDevice) +BL0939Mode = bl0939_ns.enum("BL0939Mode", is_class=True) +MODE_OPTIONS = { + "direct": BL0939Mode.BL0939_MODE_DIRECT, + "current_transformer": BL0939Mode.BL0939_MODE_CURRENT_TRANSFORMER, +} +# Must match the mode_ member initializer in bl0939.h; set_mode() is skipped +# when the config already equals it. +DEFAULT_MODE = "direct" CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(BL0939), + cv.Optional(CONF_MODE, default=DEFAULT_MODE): cv.enum( + MODE_OPTIONS, lower=True + ), cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, @@ -105,6 +117,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) + if (mode := config[CONF_MODE]) != DEFAULT_MODE: + cg.add(var.set_mode(mode)) + sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) await sensors(CONF_CURRENT_1, var.set_current_sensor_1) diff --git a/tests/components/bl0939/test-ct.esp32-idf.yaml b/tests/components/bl0939/test-ct.esp32-idf.yaml new file mode 100644 index 0000000000..8401f22163 --- /dev/null +++ b/tests/components/bl0939/test-ct.esp32-idf.yaml @@ -0,0 +1,26 @@ +substitutions: + tx_pin: GPIO12 + rx_pin: GPIO14 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + +sensor: + - platform: bl0939 + mode: current_transformer + voltage: + name: BL0939 Voltage + current_1: + name: BL0939 Current 1 + current_2: + name: BL0939 Current 2 + active_power_1: + name: BL0939 Active Power 1 + active_power_2: + name: BL0939 Active Power 2 + energy_1: + name: BL0939 Energy 1 + energy_2: + name: BL0939 Energy 2 + energy_total: + name: BL0939 Total energy From 99b61760c3e30cc1fa07b106437895d36ff0f71c Mon Sep 17 00:00:00 2001 From: Michael Smith <2015782+mikesmitty@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:37:31 -0400 Subject: [PATCH 194/268] [remote_base] Add the Pioneer WYT transmit protocol (#17175) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 18 ++++++ .../remote_base/pioneer_wyt_protocol.cpp | 30 ++++++++++ .../remote_base/pioneer_wyt_protocol.h | 55 +++++++++++++++++ esphome/core/defines.h | 1 + .../config/byte_payloads.yaml | 5 ++ .../remote_transmitter/test_byte_payloads.py | 8 ++- tests/components/remote_base/__init__.py | 1 + .../remote_base/pioneer_wyt_test.cpp | 60 +++++++++++++++++++ .../remote_transmitter/common-buttons.yaml | 12 ++++ .../remote_base/test_pioneer_wyt.py | 56 +++++++++++++++++ 10 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 esphome/components/remote_base/pioneer_wyt_protocol.cpp create mode 100644 esphome/components/remote_base/pioneer_wyt_protocol.h create mode 100644 tests/components/remote_base/pioneer_wyt_test.cpp create mode 100644 tests/unit_tests/components/remote_base/test_pioneer_wyt.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 45873f2385..e347ee96ff 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1228,6 +1228,24 @@ register_protocol_action( ) +# PioneerWyt +PioneerWytData = ns.class_("PioneerWytData") +PIONEER_WYT_SCHEMA = cv.Schema( + { + # 13 bytes get the checksum appended; a 14th byte is replaced by the recomputed checksum + cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=13, max=14)), + } +) + +register_protocol_action( + "pioneer_wyt", + "PioneerWyt", + PIONEER_WYT_SCHEMA, + _bytes_field(CONF_CODE, f"data = {PioneerWytData}({{}})"), + automation.ApplyCall("data.finalize()"), +) + + # Pronto ( ProntoData, diff --git a/esphome/components/remote_base/pioneer_wyt_protocol.cpp b/esphome/components/remote_base/pioneer_wyt_protocol.cpp new file mode 100644 index 0000000000..0c7d6c7ec9 --- /dev/null +++ b/esphome/components/remote_base/pioneer_wyt_protocol.cpp @@ -0,0 +1,30 @@ +#include "pioneer_wyt_protocol.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.pioneer_wyt"); + +// The timings and frame header are shared with tcl112 +static constexpr uint32_t HEADER_MARK_US = 3100; +static constexpr uint32_t HEADER_SPACE_US = 1650; +static constexpr uint32_t BIT_MARK_US = 500; +static constexpr uint32_t BIT_ONE_SPACE_US = 1100; +static constexpr uint32_t BIT_ZERO_SPACE_US = 350; + +void PioneerWytProtocol::encode(RemoteTransmitData *dst, const PioneerWytData &data) { + char hex_buf[format_hex_pretty_size(PioneerWytData::SIZE)]; + ESP_LOGD(TAG, "Transmit PioneerWyt: %s", format_hex_pretty_to(hex_buf, data.data(), data.size())); + dst->set_carrier_frequency(38000); + // header mark and space, a mark and space per bit, then the trailing mark + dst->reserve(dst->get_data().size() + 3 + PioneerWytData::SIZE * 8 * 2); + dst->item(HEADER_MARK_US, HEADER_SPACE_US); + for (uint8_t i = 0; i < data.size(); i++) { + for (uint8_t b = 0; b < 8; b++) + dst->item(BIT_MARK_US, (data[i] & (1 << b)) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US); + } + dst->mark(BIT_MARK_US); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_wyt_protocol.h b/esphome/components/remote_base/pioneer_wyt_protocol.h new file mode 100644 index 0000000000..9946d4230b --- /dev/null +++ b/esphome/components/remote_base/pioneer_wyt_protocol.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" +#include "remote_base.h" + +namespace esphome::remote_base { + +class PioneerWytData { + public: + static constexpr uint8_t SIZE = 14; + enum PioneerWytDataType : uint8_t { + PIONEER_WYT_TYPE_GENERAL = 0x01, + PIONEER_WYT_TYPE_FAN = 0x02, + }; + + // Make default + PioneerWytData() {} + // Make from initializer_list + PioneerWytData(std::initializer_list data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + // Make from vector + PioneerWytData(const std::vector &data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PioneerWytData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } + + uint8_t *data() { return this->data_.data(); } + const uint8_t *data() const { return this->data_.data(); } + uint8_t size() const { return this->data_.size(); } + // FAN frames add 0x0F to the byte sum + void finalize() { + this->data_[OFFSET_CS] = + sum_bytes(this->data_.data(), OFFSET_CS, this->type() == PIONEER_WYT_TYPE_FAN ? 0x0F : 0x00); + } + PioneerWytDataType type() const { return static_cast(this->data_[3]); } + uint8_t &operator[](size_t idx) { return this->data_[idx]; } + const uint8_t &operator[](size_t idx) const { return this->data_[idx]; } + + protected: + static constexpr uint8_t OFFSET_CS = SIZE - 1; + std::array data_{}; +}; + +class PioneerWytProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const PioneerWytData &data); +}; + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7c3c7d2861..4658438934 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -182,6 +182,7 @@ #define USE_REMOTE_PROTOCOL_ONKYORI #define USE_REMOTE_PROTOCOL_PANASONIC #define USE_REMOTE_PROTOCOL_PIONEER +#define USE_REMOTE_PROTOCOL_PIONEER_WYT #define USE_REMOTE_PROTOCOL_PRONTO #define USE_REMOTE_PROTOCOL_RAW #define USE_REMOTE_PROTOCOL_RC5 diff --git a/tests/component_tests/remote_transmitter/config/byte_payloads.yaml b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml index 41eefdad7f..106ac129e2 100644 --- a/tests/component_tests/remote_transmitter/config/byte_payloads.yaml +++ b/tests/component_tests/remote_transmitter/config/byte_payloads.yaml @@ -31,3 +31,8 @@ button: on_press: - remote_transmitter.transmit_midea: code: !lambda return {0xA2, 0x08, 0xFF, 0xFF, 0xFF}; + - platform: template + name: Pioneer WYT lambda + on_press: + - remote_transmitter.transmit_pioneer_wyt: + code: !lambda return {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80}; diff --git a/tests/component_tests/remote_transmitter/test_byte_payloads.py b/tests/component_tests/remote_transmitter/test_byte_payloads.py index 5bdd60cae0..0cdde16d4f 100644 --- a/tests/component_tests/remote_transmitter/test_byte_payloads.py +++ b/tests/component_tests/remote_transmitter/test_byte_payloads.py @@ -11,7 +11,7 @@ def test_byte_payloads( component_config_path: Callable[[str], Path], ) -> None: main_cpp = generate_main(component_config_path("byte_payloads.yaml")) - abb, aeha, midea = protocol_call_bodies(main_cpp) + abb, aeha, midea, pioneer_wyt = protocol_call_bodies(main_cpp) # The address length goes first, a configured id turns the automatic one off assert abb.index("set_three_byte_address(true);") < abb.index("set_source_address(") assert abb.index("auto_message_id = true;") < abb.index( @@ -31,3 +31,9 @@ def test_byte_payloads( in midea ) assert midea.rstrip().endswith("apply_call.data.finalize();") + # A 13-byte code is copied into the frame and the checksum is appended by finalize() + assert ( + "apply_call.data = remote_base::PioneerWytData([]() -> std::vector {" + in pioneer_wyt + ) + assert pioneer_wyt.rstrip().endswith("apply_call.data.finalize();") diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 36060d079c..2fe959bb16 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -17,6 +17,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "nec", "magiquest", "rc5x", + "pioneer_wyt", "drayton", "panasonic", ): diff --git a/tests/components/remote_base/pioneer_wyt_test.cpp b/tests/components/remote_base/pioneer_wyt_test.cpp new file mode 100644 index 0000000000..3854356546 --- /dev/null +++ b/tests/components/remote_base/pioneer_wyt_test.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include +#include "esphome/components/remote_base/pioneer_wyt_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +using Frame = std::array; + +// Factory remote capture: cool mode, 26°C, beeper on, display on; the last byte is the checksum +constexpr Frame COOL_26_CAPTURE = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80, 0xC1}; + +Frame bytes(const PioneerWytData &data) { + Frame out{}; + std::copy_n(data.data(), out.size(), out.begin()); + return out; +} + +} // namespace + +TEST(PioneerWytProtocolTest, RawCodeGetsTheCaptureChecksum) { + PioneerWytData data(std::vector(COOL_26_CAPTURE.begin(), COOL_26_CAPTURE.end() - 1)); + data.finalize(); + EXPECT_EQ(bytes(data), COOL_26_CAPTURE); +} + +TEST(PioneerWytProtocolTest, GivenChecksumIsRecomputed) { + PioneerWytData data(COOL_26_CAPTURE.data(), COOL_26_CAPTURE.size()); + data[PioneerWytData::SIZE - 1] = 0x00; + data.finalize(); + EXPECT_EQ(bytes(data), COOL_26_CAPTURE); +} + +// FAN frames add 0x0F to the byte sum +TEST(PioneerWytProtocolTest, FanFrameChecksumHasOffset) { + PioneerWytData data{0x23, 0xCB, 0x26, 0x02, 0x00, 0x40, 0xC0, 0x10, 0xC0, 0x00, 0x00, 0x00, 0x00}; + EXPECT_EQ(data.type(), PioneerWytData::PIONEER_WYT_TYPE_FAN); + data.finalize(); + EXPECT_EQ(data[PioneerWytData::SIZE - 1], 0xF5); +} + +TEST(PioneerWytProtocolTest, EncodesHeaderAndAllBits) { + RemoteTransmitData dst; + PioneerWytProtocol().encode(&dst, PioneerWytData(COOL_26_CAPTURE.data(), COOL_26_CAPTURE.size())); + // header mark and space, a mark and space per bit, then the trailing mark + const auto &raw = dst.get_data(); + ASSERT_EQ(raw.size(), 3u + PioneerWytData::SIZE * 8 * 2); + EXPECT_EQ(dst.get_carrier_frequency(), 38000u); + EXPECT_EQ(raw[0], 3100); + EXPECT_EQ(raw[1], -1650); + // 0x23 LSB first: bit 0 is one, bit 2 is zero + EXPECT_EQ(raw[3], -1100); + EXPECT_EQ(raw[7], -350); + EXPECT_EQ(raw.back(), 500); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 7fa8cd1132..dff4e8f9c5 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -127,6 +127,18 @@ button: rc_code_2: 0xA506 repeat: times: 2 + - platform: template + name: Pioneer WYT + on_press: + remote_transmitter.transmit_pioneer_wyt: + # Captured from the factory remote: cool mode, 26°C. The encoder appends the checksum. + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80] + - platform: template + name: Pioneer WYT With Checksum + on_press: + remote_transmitter.transmit_pioneer_wyt: + # Same capture with its checksum byte included + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80, 0xC1] - platform: template name: RC Switch Raw on_press: diff --git a/tests/unit_tests/components/remote_base/test_pioneer_wyt.py b/tests/unit_tests/components/remote_base/test_pioneer_wyt.py new file mode 100644 index 0000000000..fd9e5afe4a --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_pioneer_wyt.py @@ -0,0 +1,56 @@ +"""Schema checks for the pioneer_wyt transmit action.""" + +import pytest + +from esphome.automation import ACTION_REGISTRY +import esphome.config_validation as cv +from esphome.core import Lambda + +# Factory remote capture: cool mode, 26°C, without the trailing checksum byte +RAW_CODE = [ + 0x23, + 0xCB, + 0x26, + 0x01, + 0x00, + 0x24, + 0x03, + 0x05, + 0x00, + 0x00, + 0x00, + 0x00, + 0x80, +] +RAW_CODE_CHECKSUM = 0xC1 + + +@pytest.fixture +def schema() -> cv.Schema: + return ACTION_REGISTRY["remote_transmitter.transmit_pioneer_wyt"].raw_schema + + +def test_code_checksum_matches_capture() -> None: + assert sum(RAW_CODE) & 0xFF == RAW_CODE_CHECKSUM + + +def test_code_accepts_with_and_without_checksum_byte(schema: cv.Schema) -> None: + with_checksum = [*RAW_CODE, RAW_CODE_CHECKSUM] + assert schema({"code": RAW_CODE})["code"] == RAW_CODE + assert schema({"code": with_checksum})["code"] == with_checksum + + +@pytest.mark.parametrize("code", [RAW_CODE[:-1], [*RAW_CODE, RAW_CODE_CHECKSUM, 0x00]]) +def test_code_rejects_other_lengths(schema: cv.Schema, code: list[int]) -> None: + with pytest.raises(cv.Invalid): + schema({"code": code}) + + +def test_code_accepts_lambda(schema: cv.Schema) -> None: + code = Lambda("return {0x23, 0xCB};") + assert isinstance(schema({"code": code})["code"], Lambda) + + +def test_code_is_required(schema: cv.Schema) -> None: + with pytest.raises(cv.Invalid): + schema({}) From af8a0739f052c4e6ddac0179d0490e24dc552811 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:46:11 -1000 Subject: [PATCH 195/268] [web_server] Self contained AP mode: embedded interface served as a captive portal (#18517) --- esphome/components/captive_portal/__init__.py | 28 +-- .../captive_portal/captive_portal.cpp | 29 +-- .../captive_portal/captive_portal.h | 28 +-- esphome/components/web_server/__init__.py | 132 +++++++++++++- esphome/components/web_server/web_server.cpp | 61 ++++++- esphome/components/web_server/web_server.h | 17 ++ .../components/web_server_base/__init__.py | 31 +++- .../components/web_server_base/captive_dns.h | 63 +++++++ .../dns_server_esp32_idf.cpp | 17 +- .../dns_server_esp32_idf.h | 13 +- .../web_server_idf/web_server_idf.cpp | 4 +- .../web_server_idf/web_server_idf.h | 3 +- esphome/components/wifi/wifi_component.cpp | 78 +++++--- esphome/components/wifi/wifi_component.h | 6 +- .../wifi/wifi_component_esp_idf.cpp | 27 ++- esphome/core/defines.h | 3 + .../web_server/common-ap-fallback.yaml | 18 ++ .../components/web_server/common-ap-mode.yaml | 9 + .../test-ap-fallback.esp32-idf.yaml | 2 + .../web_server/test-ap-mode.esp32-idf.yaml | 2 + .../web_server/test-ap-mode.esp8266-ard.yaml | 2 + .../unit_tests/components/test_web_server.py | 172 ++++++++++++++++++ 22 files changed, 600 insertions(+), 145 deletions(-) create mode 100644 esphome/components/web_server_base/captive_dns.h rename esphome/components/{captive_portal => web_server_base}/dns_server_esp32_idf.cpp (93%) rename esphome/components/{captive_portal => web_server_base}/dns_server_esp32_idf.h (58%) create mode 100644 tests/components/web_server/common-ap-fallback.yaml create mode 100644 tests/components/web_server/common-ap-mode.yaml create mode 100644 tests/components/web_server/test-ap-fallback.esp32-idf.yaml create mode 100644 tests/components/web_server/test-ap-mode.esp32-idf.yaml create mode 100644 tests/components/web_server/test-ap-mode.esp8266-ard.yaml create mode 100644 tests/unit_tests/components/test_web_server.py diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 3bdea03bf5..8187dac008 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -4,7 +4,6 @@ import esphome.codegen as cg from esphome.components import web_server_base, wifi from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID from esphome.components.wifi import DOMAIN as WIFI_DOMAIN -from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_AP, @@ -16,7 +15,6 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RP2, PLATFORM_RTL87XX, - PlatformFramework, ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority @@ -78,17 +76,7 @@ def _final_validate(config: ConfigType) -> None: "Add 'ap:' to your WiFi configuration to enable the captive portal." ) - # Register socket needs for DNS server and additional HTTP connections - # - 1 UDP socket for DNS server - # - 3 TCP sockets for captive portal detection probes + configuration requests - # OS captive portal detection makes multiple probe requests that stay in TIME_WAIT. - # Need headroom for actual user configuration requests. - # LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts. - # The listening socket is registered by web_server_base (shared HTTP server). - from esphome.components import socket - - socket.consume_sockets(3, "captive_portal")(config) - socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config) + web_server_base.consume_captive_dns_sockets(config, DOMAIN) FINAL_VALIDATE_SCHEMA = _final_validate @@ -108,16 +96,4 @@ async def to_code(config: ConfigType) -> None: if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_CAPTIVE_PORTAL_GZIP") - if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): - cg.add_library("DNSServer", None) - - -# Only compile the ESP-IDF DNS server when using ESP-IDF framework -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "dns_server_esp32_idf.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, - } -) + web_server_base.add_captive_dns_library() diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 36e9ec406d..0aa48bc3ea 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -6,9 +6,6 @@ #include "esphome/core/string_ref.h" #include "esphome/components/wifi/scan_list.h" #include "esphome/components/wifi/wifi_component.h" -#ifdef USE_PROVISIONING -#include "esphome/components/provisioning/provisioning.h" -#endif #include "captive_index.h" namespace esphome::captive_portal { @@ -81,20 +78,6 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { void CaptivePortal::setup() { // Disable loop by default - will be enabled when captive portal starts this->disable_loop(); -#ifdef USE_PROVISIONING - // The captive portal is a provisioning surface: once the provisioning window - // has closed, stop serving it. WiFi's own closed-callback shuts down the - // access point the portal runs on, and the gated fallback in WiFiComponent's - // loop() ensures neither is started again afterwards. - if (provisioning::global_provisioning_manager != nullptr) { - provisioning::global_provisioning_manager->add_on_closed_callback([this]() { - if (this->active_) { - ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal"); - this->end(); - } - }); - } -#endif } void CaptivePortal::start() { this->base_->init(); @@ -102,17 +85,7 @@ void CaptivePortal::start() { this->base_->add_handler_without_auth(this); } - network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); - -#if defined(USE_ESP32) - // Create DNS server instance for ESP-IDF - this->dns_server_ = make_unique(); - this->dns_server_->start(ip); -#elif defined(USE_ARDUINO) - this->dns_server_ = make_unique(); - this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); - this->dns_server_->start(53, ESPHOME_F("*"), ip); -#endif + this->dns_.start(wifi::global_wifi_component->wifi_soft_ap_ip()); this->initialized_ = true; this->active_ = true; diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index b47af9d978..013ba25b5f 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -1,16 +1,11 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_CAPTIVE_PORTAL -#include -#if defined(USE_ESP32) -#include "dns_server_esp32_idf.h" -#elif defined(USE_ARDUINO) -#include -#endif #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include "esphome/components/web_server_base/web_server_base.h" +#include "esphome/components/web_server_base/captive_dns.h" namespace esphome::captive_portal { @@ -19,17 +14,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { CaptivePortal(web_server_base::WebServerBase *base); void setup() override; void dump_config() override; - void loop() override { -#if defined(USE_ESP32) - if (this->dns_server_ != nullptr) { - this->dns_server_->process_next_request(); - } -#elif defined(USE_ARDUINO) - if (this->dns_server_ != nullptr) { - this->dns_server_->processNextRequest(); - } -#endif - } + void loop() override { this->dns_.loop(); } float get_setup_priority() const override; void start(); bool is_active() const { return this->active_; } @@ -37,10 +22,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { this->active_ = false; this->disable_loop(); // Stop processing DNS requests this->base_->deinit(); - if (this->dns_server_ != nullptr) { - this->dns_server_->stop(); - this->dns_server_ = nullptr; - } + this->dns_.stop(); } bool canHandle(AsyncWebServerRequest *request) const override { @@ -60,9 +42,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { web_server_base::WebServerBase *base_; bool initialized_{false}; bool active_{false}; -#if defined(USE_ARDUINO) || defined(USE_ESP32) - std::unique_ptr dns_server_{nullptr}; -#endif + web_server_base::CaptiveDNS dns_; }; extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 4f233e9965..c48c72c06e 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -13,6 +13,7 @@ from esphome.components.logger import request_log_listener from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import ( + CONF_AP, CONF_AUTH, CONF_COMPRESSION, CONF_CSS_INCLUDE, @@ -24,15 +25,19 @@ from esphome.const import ( CONF_JS_URL, CONF_LOCAL, CONF_LOG, + CONF_MANUAL_IP, CONF_NAME, + CONF_NETWORKS, CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_STATIC_IP, CONF_TYPE, CONF_USERNAME, CONF_VERSION, CONF_WEB_SERVER, CONF_WEB_SERVER_ID, + CONF_WIFI, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -47,7 +52,21 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["json", "web_server_base"] + +def AUTO_LOAD() -> list[str]: + # No config parameter: a dynamic auto-load would satisfy ota.web_server's dependency too late + auto_load = ["json", "web_server_base"] + # The AP mode DNS server needs socket; CORE.raw_config already has a wifi block from a package + wifi = CORE.raw_config.get(CONF_WIFI) if CORE.raw_config else None + if ( + CORE.is_esp32 + and wifi is not None + and (not isinstance(wifi, dict) or CONF_AP in wifi) + ): + auto_load.append("socket") + return auto_load + + DOMAIN = "web_server" AUTH_TYPE_BASIC = "basic" @@ -211,9 +230,6 @@ def _final_validate_sorting(config: ConfigType) -> None: ) -FINAL_VALIDATE_SCHEMA = _final_validate_sorting - - def _consume_web_server_sockets(config: ConfigType) -> ConfigType: """Register socket needs for web_server component.""" from esphome.components import socket @@ -340,6 +356,108 @@ async def add_entity_config(entity: MockObj, config: ConfigType) -> None: ) +def wifi_is_ap_only(wifi_config: ConfigType | None) -> bool: + """AP only: an access point and no network to join, so the device is only ever reached + through its own AP.""" + return ( + wifi_config is not None + and CONF_AP in wifi_config + and not wifi_config.get(CONF_NETWORKS) + ) + + +def serve_local(config: ConfigType, wifi_config: ConfigType | None) -> bool: + """Embed the interface unless ``local:`` says otherwise; AP only WiFi has no internet + for the hosted page. Version 1 has no local mode.""" + if (local := config.get(CONF_LOCAL)) is not None: + return local + return config[CONF_VERSION] != 1 and wifi_is_ap_only(wifi_config) + + +def _captive_portal_configured(full_config: ConfigType) -> bool: + # Imported here: captive_portal pulls in wifi, which sensor (through web_server) must not + from esphome.components.captive_portal import DOMAIN as CAPTIVE_PORTAL_DOMAIN + + return CAPTIVE_PORTAL_DOMAIN in full_config + + +def serve_captive(config: ConfigType, full_config: ConfigType) -> bool: + """Serve the embedded interface as a captive portal while the AP is up, unless captive_portal + owns that role. Port 80 only: the OS probes and the DHCP portal URI never use another port.""" + wifi_config = full_config.get(CONF_WIFI) + return ( + not _captive_portal_configured(full_config) + and config[CONF_PORT] == 80 + and wifi_config is not None + and CONF_AP in wifi_config + and serve_local(config, wifi_config) + ) + + +def _final_validate_ap_mode(config: ConfigType) -> None: + full_config = fv.full_config.get() + wifi_config = full_config.get(CONF_WIFI) + captive = serve_captive(config, full_config) + local = serve_local(config, wifi_config) + ap_only = wifi_is_ap_only(wifi_config) + if captive: + web_server_base.consume_captive_dns_sockets(config, DOMAIN) + # Surface behavior that the config does not spell out. + if local and CONF_LOCAL not in config: + _LOGGER.info( + "WiFi is AP only: embedding the web interface in the firmware " + "(local: true, roughly 13 KB of flash for version 2, 78 KB for version 3)%s. " + "Set 'local: false' to load it from the internet instead.", + " and serving it as a captive portal on the access point" + if captive + else "", + ) + elif captive: + _LOGGER.info( + "web_server will act as a captive portal while the %saccess point is active.", + "" if ap_only else "fallback ", + ) + # An embedded interface on an AP (AP only, or a fallback with local: true) that is not + # on port 80 cannot be a captive portal; say so, unless captive_portal has that role. + if ( + local + and config[CONF_PORT] != 80 + and wifi_config is not None + and CONF_AP in wifi_config + and not _captive_portal_configured(full_config) + ): + ap_ip = "192.168.4.1" + if (manual_ip := wifi_config[CONF_AP].get(CONF_MANUAL_IP)) is not None: + ap_ip = str(manual_ip[CONF_STATIC_IP]) + _LOGGER.warning( + "web_server uses port %d, so the interface cannot open automatically on the " + "%saccess point (captive portal detection only works on port 80); open " + "http://%s:%d/ manually, or remove 'port:' to use 80.", + config[CONF_PORT], + "" if ap_only else "fallback ", + ap_ip, + config[CONF_PORT], + ) + if ap_only and not local: + _LOGGER.warning( + "WiFi is AP only and the web_server interface is loaded from the internet, " + "which browsers on the access point usually cannot reach; the page stays " + "blank. %s so the interface is embedded in the firmware.", + "Remove 'local: false'" + if config.get(CONF_LOCAL) is False + else "Migrate to version 2 or 3", + ) + + +def _final_validate(config: ConfigType) -> None: + # Called one after the other rather than via cv.All: these return None. + _final_validate_sorting(config) + _final_validate_ap_mode(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def build_index_html(config: ConfigType) -> str: html = "" css_include = config.get(CONF_CSS_INCLUDE) @@ -459,8 +577,12 @@ async def to_code(config: ConfigType) -> None: # include_internal_ is false in C++; only emit the setter to turn it on. if config[CONF_INCLUDE_INTERNAL]: cg.add(var.set_include_internal(True)) - if CONF_LOCAL in config and config[CONF_LOCAL]: + if serve_local(config, CORE.config.get(CONF_WIFI)): cg.add_define("USE_WEBSERVER_LOCAL") + if serve_captive(config, CORE.config): + # AP mode: DNS server plus redirect of unknown URLs so phones open the interface + cg.add_define("USE_WEBSERVER_CAPTIVE") + web_server_base.add_captive_dns_library() if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_WEBSERVER_GZIP") diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 5fa3ea054b..b0da010561 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -45,6 +45,10 @@ #include "esphome/components/radio_frequency/radio_frequency.h" #endif +#ifdef USE_WEBSERVER_CAPTIVE +#include "esphome/components/wifi/wifi_component.h" +#endif + #ifdef USE_WEBSERVER_LOCAL #if USE_WEBSERVER_VERSION == 2 #include "server_index_v2.h" @@ -388,7 +392,15 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou } #endif -WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} +#ifdef USE_WEBSERVER_CAPTIVE +WebServer *global_web_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + +WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) { +#ifdef USE_WEBSERVER_CAPTIVE + global_web_server = this; +#endif +} // Kept out of the callers so the 64 bit division is emitted once __attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast(millis_64() / 1000); } @@ -429,6 +441,11 @@ void WebServer::setup() { this->base_->add_handler(&this->events_); #endif this->base_->add_handler(this); +#ifdef USE_WEBSERVER_CAPTIVE + // Not-found fallback (outside the auth middleware): the OS captive portal probes hit + // arbitrary URLs and must get the redirect without credentials. + this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *request) { this->handle_not_found_(request); }); +#endif // OTA is now handled by the web_server OTA platform @@ -444,16 +461,54 @@ void WebServer::setup() { }); } void WebServer::loop() { - // No SSE clients connected; stop looping until a new client connects via + bool keep_looping = this->events_.loop(); +#ifdef USE_WEBSERVER_CAPTIVE + this->dns_.loop(); + keep_looping |= this->dns_.is_running(); +#endif + // No SSE clients connected (and no captive DNS to serve); stop looping until a new client connects via // enable_loop_soon_any_context(). This is safe because: // - set_interval/set_timeout/defer run via the Scheduler, independent of loop() // - deferrable_send_state early-outs when no clients are connected // - try_send_nodefer (log, ping) iterates sessions which are empty // - REST API handlers use defer() which runs via the Scheduler - if (!this->events_.loop()) + if (!keep_looping) this->disable_loop(); } +#ifdef USE_WEBSERVER_CAPTIVE +void WebServer::start_captive() { + // Avoid repeating the log and enable_loop on a second start + if (this->dns_.is_running()) + return; + network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + if (!this->dns_.start(ip)) { + ESP_LOGW(TAG, "AP mode: captive DNS failed to start; open http://%s/ manually", ip.str_to(ip_buf)); + return; + } + this->enable_loop(); + ESP_LOGI(TAG, "AP mode: serving the web interface as captive portal at http://%s/", ip.str_to(ip_buf)); +} + +void WebServer::end_captive() { this->dns_.stop(); } + +void WebServer::handle_not_found_(AsyncWebServerRequest *request) { + // OS captive portal probe while the AP is up: a redirect rather than the page itself, because + // the interface resolves its /events and REST paths relative to the page URL. + if (this->dns_.is_running() && request->method() == HTTP_GET) { + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + wifi::global_wifi_component->wifi_soft_ap_ip().str_to(ip_buf); + // Captive mode requires port 80 (enforced at validation), so no port suffix + char location[7 + network::IP_ADDRESS_BUFFER_SIZE + 1]; + buf_append_printf(location, sizeof(location), 0, "http://%s/", ip_buf); + request->redirect(location); + return; + } + request->send(404); +} +#endif + #ifdef USE_LOGGER void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 1f9b6cb934..d3bd1ae0da 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -4,6 +4,7 @@ #include "esphome/components/json/json_util.h" #include "esphome/components/web_server_base/web_server_base.h" +#include "esphome/components/web_server_base/captive_dns.h" #ifdef USE_WEBSERVER #include "esphome/core/component.h" #include "esphome/core/entity_base.h" @@ -277,6 +278,14 @@ class WebServer final : public Component, public AsyncWebHandler { /// Handle an index request under '/'. void handle_index_request(AsyncWebServerRequest *request); +#ifdef USE_WEBSERVER_CAPTIVE + /// AP mode: DNS answers every name with the AP address and unknown URLs redirect to the interface. + /// wifi calls start before setup(), so it may touch nothing but dns_ (enable_loop is a no-op then). + void start_captive(); + void end_captive(); + bool is_captive() const { return this->dns_.is_running(); } +#endif + /// Return the webserver configuration as JSON. json::SerializationBuffer<> get_config_json(); @@ -606,6 +615,10 @@ class WebServer final : public Component, public AsyncWebHandler { #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif +#ifdef USE_WEBSERVER_CAPTIVE + void handle_not_found_(AsyncWebServerRequest *request); + web_server_base::CaptiveDNS dns_; +#endif #if USE_WEBSERVER_VERSION == 1 const char *css_url_{nullptr}; @@ -704,5 +717,9 @@ class WebServer final : public Component, public AsyncWebHandler { #endif }; +#ifdef USE_WEBSERVER_CAPTIVE +extern WebServer *global_web_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + } // namespace esphome::web_server #endif diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index c94eea1190..1625153749 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,8 +1,9 @@ from pathlib import Path import esphome.codegen as cg +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, PlatformFramework from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.helpers import copy_file_if_changed @@ -27,6 +28,22 @@ WebServerBase = web_server_base_ns.class_("WebServerBase") CONF_WEB_SERVER_BASE_ID = "web_server_base_id" +def consume_captive_dns_sockets(config: ConfigType, name: str) -> None: + """Register the sockets a captive portal needs on top of the shared HTTP server: + 1 UDP socket for the DNS server and 3 TCP sockets for the OS captive portal probes, + which make several requests that linger in TIME_WAIT.""" + from esphome.components import socket + + socket.consume_sockets(3, name)(config) + socket.consume_sockets(1, name, socket.SocketType.UDP)(config) + + +def add_captive_dns_library() -> None: + """Pull in the Arduino DNSServer library used by CaptiveDNS off ESP32.""" + if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): + cg.add_library("DNSServer", None) + + def _consume_web_server_base_sockets(config: ConfigType) -> ConfigType: """Register the shared listening socket for the HTTP server. @@ -82,3 +99,15 @@ async def to_code(config: ConfigType) -> None: cg.add_platformio_option("extra_scripts", ["pre:fix_rp2040_hash.py"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") + + +# The DNS server used for captive portals on ESP32; other platforms use the Arduino +# DNSServer library. Its source is also guarded by USE_CAPTIVE_PORTAL / USE_WEBSERVER_CAPTIVE. +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "dns_server_esp32_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + } +) diff --git a/esphome/components/web_server_base/captive_dns.h b/esphome/components/web_server_base/captive_dns.h new file mode 100644 index 0000000000..0d027d3c40 --- /dev/null +++ b/esphome/components/web_server_base/captive_dns.h @@ -0,0 +1,63 @@ +#pragma once +#include "esphome/core/defines.h" +// DNS server that answers every name with the access point address, so a phone joining the +// AP runs its captive portal check against the device. Shared by captive_portal and the +// web_server AP mode; hides the ESP32 (own implementation) vs Arduino (DNSServer library) split. +#if defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE) +#include + +#include "esphome/components/network/ip_address.h" +#include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" +#if defined(USE_ESP32) +#include "dns_server_esp32_idf.h" +#elif defined(USE_ARDUINO) +#include +#endif + +namespace esphome::web_server_base { + +// The server object only exists while running, so an idle owner (AP not up) pays one pointer. +class CaptiveDNS { + public: + /// Returns false when the DNS server could not start; nothing is kept, so is_running() stays false. + bool start(const network::IPAddress &ip) { + if (this->dns_server_ != nullptr) + return true; + auto server = make_unique(); +#if defined(USE_ESP32) + const bool started = server->start(ip); +#elif defined(USE_ARDUINO) + server->setErrorReplyCode(DNSReplyCode::NoError); + const bool started = server->start(53, ESPHOME_F("*"), ip); +#endif + if (!started) + return false; + this->dns_server_ = std::move(server); + return true; + } + void stop() { + if (this->dns_server_ == nullptr) + return; + this->dns_server_->stop(); + this->dns_server_ = nullptr; + } + /// Answer one pending query; call from the owner's loop() while running. + void loop() { + if (this->dns_server_ == nullptr) + return; +#if defined(USE_ESP32) + this->dns_server_->process_next_request(); +#elif defined(USE_ARDUINO) + this->dns_server_->processNextRequest(); +#endif + } + bool is_running() const { return this->dns_server_ != nullptr; } + + protected: + // ESP32: web_server_base::DNSServer from dns_server_esp32_idf.h; Arduino: the library class. + std::unique_ptr dns_server_; +}; + +} // namespace esphome::web_server_base +#endif // USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/web_server_base/dns_server_esp32_idf.cpp similarity index 93% rename from esphome/components/captive_portal/dns_server_esp32_idf.cpp rename to esphome/components/web_server_base/dns_server_esp32_idf.cpp index d7a46373b8..c87433fa56 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/web_server_base/dns_server_esp32_idf.cpp @@ -1,5 +1,5 @@ #include "dns_server_esp32_idf.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -7,9 +7,9 @@ #include #include -namespace esphome::captive_portal { +namespace esphome::web_server_base { -ESPHOME_LOG_TAG(TAG, "captive_portal.dns"); +ESPHOME_LOG_TAG(TAG, "web_server_base.dns"); // DNS constants static constexpr uint16_t DNS_PORT = 53; @@ -46,7 +46,7 @@ struct DNSAnswer { uint32_t ip_addr; } __attribute__((packed)); -void DNSServer::start(const network::IPAddress &ip) { +bool DNSServer::start(const network::IPAddress &ip) { this->server_ip_ = ip; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; @@ -57,7 +57,7 @@ void DNSServer::start(const network::IPAddress &ip) { this->socket_ = socket::socket_ip_loop_monitored(SOCK_DGRAM, IPPROTO_UDP).release(); if (this->socket_ == nullptr) { ESP_LOGE(TAG, "Socket create failed"); - return; + return false; } // Set socket options @@ -72,9 +72,10 @@ void DNSServer::start(const network::IPAddress &ip) { if (err != 0) { ESP_LOGE(TAG, "Bind failed: %d", errno); this->destroy_socket_(); - return; + return false; } ESP_LOGV(TAG, "Bound to port %d", DNS_PORT); + return true; } void DNSServer::stop() { @@ -202,6 +203,6 @@ void DNSServer::process_next_request() { } } -} // namespace esphome::captive_portal +} // namespace esphome::web_server_base -#endif // USE_ESP32 +#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/web_server_base/dns_server_esp32_idf.h similarity index 58% rename from esphome/components/captive_portal/dns_server_esp32_idf.h rename to esphome/components/web_server_base/dns_server_esp32_idf.h index b30856c204..2b10af332a 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/web_server_base/dns_server_esp32_idf.h @@ -1,15 +1,18 @@ #pragma once -#ifdef USE_ESP32 +#include "esphome/core/defines.h" +// ESP32 backend for CaptiveDNS: answers every A query with the access point address. +#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) #include "esphome/core/helpers.h" #include "esphome/components/network/ip_address.h" #include "esphome/components/socket/socket.h" -namespace esphome::captive_portal { +namespace esphome::web_server_base { class DNSServer { public: - void start(const network::IPAddress &ip); + /// Returns false when the socket could not be created or bound. + bool start(const network::IPAddress &ip); void stop(); void process_next_request(); @@ -27,6 +30,6 @@ class DNSServer { uint8_t buffer_[DNS_BUFFER_SIZE]; }; -} // namespace esphome::captive_portal +} // namespace esphome::web_server_base -#endif // USE_ESP32 +#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 953351f259..2153e232a0 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -335,9 +335,9 @@ StringRef AsyncWebServerRequest::url_to(std::span buffer) co return StringRef(buffer.data(), decoded_len); } -void AsyncWebServerRequest::redirect(const std::string &url) { +void AsyncWebServerRequest::redirect(const char *url) { httpd_resp_set_status(*this, "302 Found"); - httpd_resp_set_hdr(*this, "Location", url.c_str()); + httpd_resp_set_hdr(*this, "Location", url); httpd_resp_set_hdr(*this, "Connection", "close"); httpd_resp_send(*this, nullptr, 0); } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 581ba8323b..f9094b05c5 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -129,7 +129,8 @@ class AsyncWebServerRequest { void requestAuthentication() const; #endif - void redirect(const std::string &url); + void redirect(const char *url); + void redirect(const std::string &url) { this->redirect(url.c_str()); } inline void ESPHOME_ALWAYS_INLINE send(AsyncWebServerResponse *response) { httpd_resp_send(*this, response->get_content_data(), response->get_content_size()); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f973d146e5..62732e0915 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -36,6 +36,9 @@ #ifdef USE_CAPTIVE_PORTAL #include "esphome/components/captive_portal/captive_portal.h" #endif +#ifdef USE_WEBSERVER_CAPTIVE +#include "esphome/components/web_server/web_server.h" +#endif #ifdef USE_IMPROV_BLE #include "esphome/components/improv_ble/improv_ble_component.h" @@ -653,12 +656,12 @@ void WiFiComponent::setup() { #endif #if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) - // The access point is a provisioning surface: once the provisioning window has - // closed, shut it down (mirrors the teardown done on a successful connection). - // The captive portal registers its own closed-callback, and the fallback block - // in loop() is gated so neither is started again afterwards. + // The access point and the portals on it are provisioning surfaces: once the window has + // closed, end them (mirrors the teardown on a successful connection). The fallback block in + // loop() is gated so none of them is started again afterwards. if (provisioning::global_provisioning_manager != nullptr) { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_ if (this->ap_setup_) { ESP_LOGD(TAG, "Provisioning window closed; disabling AP"); #ifdef USE_WIFI_AP_EXCLUSIVE @@ -778,15 +781,13 @@ void WiFiComponent::start() { ESP_LOGV(TAG, "Setting Output Power Option failed"); } #ifdef USE_CAPTIVE_PORTAL - if (captive_portal::global_captive_portal != nullptr) { - // Where the radio scans alongside the AP, the portal can list networks. - if (!WIFI_AP_EXCLUSIVE) { - this->wifi_sta_pre_setup_(); - this->start_scanning(); - } - captive_portal::global_captive_portal->start(); + // Where the radio scans alongside the AP, the portal can list networks. + if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) { + this->wifi_sta_pre_setup_(); + this->start_scanning(); } #endif + this->start_ap_portal_(); #endif // USE_WIFI_AP } #ifdef USE_IMPROV_BLE @@ -847,8 +848,8 @@ void WiFiComponent::loop() { this->check_connecting_finished(now); break; } - // Use longer cooldown when captive portal/improv is active to avoid disrupting user config - bool portal_active = this->is_captive_portal_active_() || this->is_improv_ble_active_(); + // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP + bool portal_active = this->is_ap_portal_active_() || this->is_improv_ble_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -944,16 +945,15 @@ void WiFiComponent::loop() { this->ap_exclusive_changed_ = now; #endif this->setup_ap_config_(); + // Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns. + if (!WIFI_AP_EXCLUSIVE || this->ap_setup_) { #ifdef USE_CAPTIVE_PORTAL - // Where the AP runs on its own, a portal with no AP behind it would - // only stretch the cooldowns. - if (captive_portal::global_captive_portal != nullptr && (!WIFI_AP_EXCLUSIVE || this->ap_setup_)) { // Reset so we force one full scan after captive portal starts // (previous scans were filtered because captive portal wasn't active yet) this->has_completed_scan_after_captive_portal_start_ = false; - captive_portal::global_captive_portal->start(); - } #endif + this->start_ap_portal_(); + } } } #endif // USE_WIFI_AP @@ -1118,11 +1118,7 @@ void WiFiComponent::pause_exclusive_ap_() { #endif void WiFiComponent::disable_ap_() { -#ifdef USE_CAPTIVE_PORTAL - if (this->is_captive_portal_active_()) { - captive_portal::global_captive_portal->end(); - } -#endif + this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); } @@ -2073,10 +2069,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { break; case WiFiRetryPhase::RESTARTING_ADAPTER: - // Skip actual adapter restart if captive portal/improv is active + // Skip actual adapter restart if a portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan - // without disrupting the captive portal/improv connection - if (!this->is_captive_portal_active_() && !this->is_improv_ble_active_()) { + // without disrupting the portal/improv connection + if (!this->is_ap_portal_active_() && !this->is_improv_ble_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2335,6 +2331,36 @@ bool WiFiComponent::is_captive_portal_active_() { return false; #endif } + +bool WiFiComponent::is_ap_portal_active_() { +#ifdef USE_WEBSERVER_CAPTIVE + if (web_server::global_web_server->is_captive()) + return true; +#endif + return this->is_captive_portal_active_(); +} + +// global_web_server needs no null check: codegen always instantiates WebServer when +// USE_WEBSERVER_CAPTIVE is defined, and the constructor assigns the global. +void WiFiComponent::start_ap_portal_() { +#ifdef USE_CAPTIVE_PORTAL + if (captive_portal::global_captive_portal != nullptr) + captive_portal::global_captive_portal->start(); +#endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->start_captive(); +#endif +} + +void WiFiComponent::end_ap_portal_() { +#ifdef USE_CAPTIVE_PORTAL + if (this->is_captive_portal_active_()) + captive_portal::global_captive_portal->end(); +#endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->end_captive(); +#endif +} bool WiFiComponent::is_improv_ble_active_() { #ifdef USE_IMPROV_BLE return improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 73344f66fa..c1f4061a53 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -713,7 +713,7 @@ class WiFiComponent final : public Component { protected: #ifdef USE_WIFI_AP void setup_ap_config_(); - /// End the captive portal and turn the AP off. + /// End the portals and turn the AP off. void disable_ap_(); #ifdef USE_WIFI_AP_EXCLUSIVE /// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout. @@ -832,6 +832,10 @@ class WiFiComponent final : public Component { network::IPAddress wifi_dns_ip_(int num); bool is_captive_portal_active_(); + /// captive_portal or the web_server AP mode is serving a user on the access point + bool is_ap_portal_active_(); + void start_ap_portal_(); + void end_ap_portal_(); bool is_improv_ble_active_(); #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 388e3edb98..bdca350dde 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1334,21 +1334,18 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { return false; } -#if defined(USE_CAPTIVE_PORTAL) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) - // Configure DHCP Option 114 (Captive Portal URI) if captive portal is enabled - // This provides a standards-compliant way for clients to discover the captive portal - if (captive_portal::global_captive_portal != nullptr) { - // Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy - static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null - memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates - network::IPAddress(&info.ip).str_to(captive_portal_uri + 7); - err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri, - strlen(captive_portal_uri)); - if (err != ESP_OK) { - ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err)); - } else { - ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri); - } +#if (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) + // DHCP option 114 (captive portal URI) lets clients find the portal; either define means one exists + // Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy + static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null + memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates + network::IPAddress(&info.ip).str_to(captive_portal_uri + 7); + err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri, + strlen(captive_portal_uri)); + if (err != ESP_OK) { + ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err)); + } else { + ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri); } #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 4658438934..5ae701c552 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -460,6 +460,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP @@ -597,6 +598,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -656,6 +658,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/components/web_server/common-ap-fallback.yaml b/tests/components/web_server/common-ap-fallback.yaml new file mode 100644 index 0000000000..0a4e1bc42d --- /dev/null +++ b/tests/components/web_server/common-ap-fallback.yaml @@ -0,0 +1,18 @@ +# STA with AP fallback plus local: true opts the fallback into captive AP mode; exercises +# the runtime start (fallback branch in wifi loop) and end (on STA connect, and when the +# provisioning window closes) paths. +provisioning: + timeout: 1min + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + ap: + ssid: "ESPHome-Test" + password: "Test1234!" + +web_server: + local: true diff --git a/tests/components/web_server/common-ap-mode.yaml b/tests/components/web_server/common-ap-mode.yaml new file mode 100644 index 0000000000..dc522e3178 --- /dev/null +++ b/tests/components/web_server/common-ap-mode.yaml @@ -0,0 +1,9 @@ +# AP mode: with a WiFi access point and the interface embedded in the firmware, web_server +# runs its own captive portal (DNS server, unknown URLs redirect to the page). +wifi: + ap: + ssid: "ESPHome-Test" + password: "Test1234!" + +web_server: + local: true diff --git a/tests/components/web_server/test-ap-fallback.esp32-idf.yaml b/tests/components/web_server/test-ap-fallback.esp32-idf.yaml new file mode 100644 index 0000000000..172fb7de71 --- /dev/null +++ b/tests/components/web_server/test-ap-fallback.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + web_server: !include common-ap-fallback.yaml diff --git a/tests/components/web_server/test-ap-mode.esp32-idf.yaml b/tests/components/web_server/test-ap-mode.esp32-idf.yaml new file mode 100644 index 0000000000..077a7da009 --- /dev/null +++ b/tests/components/web_server/test-ap-mode.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + web_server: !include common-ap-mode.yaml diff --git a/tests/components/web_server/test-ap-mode.esp8266-ard.yaml b/tests/components/web_server/test-ap-mode.esp8266-ard.yaml new file mode 100644 index 0000000000..077a7da009 --- /dev/null +++ b/tests/components/web_server/test-ap-mode.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + web_server: !include common-ap-mode.yaml diff --git a/tests/unit_tests/components/test_web_server.py b/tests/unit_tests/components/test_web_server.py new file mode 100644 index 0000000000..3015527c18 --- /dev/null +++ b/tests/unit_tests/components/test_web_server.py @@ -0,0 +1,172 @@ +"""Tests for the web_server AP mode helpers.""" + +import logging + +import pytest + +from esphome.components.web_server import ( + _final_validate_ap_mode, + serve_captive, + serve_local, +) +from esphome.const import ( + CONF_AP, + CONF_LOCAL, + CONF_NETWORKS, + CONF_PORT, + CONF_SSID, + CONF_VERSION, + CONF_WIFI, +) +import esphome.final_validate as fv + +AP_ONLY = {CONF_AP: {}} +AP_FALLBACK = {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]} +STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]} + + +@pytest.mark.parametrize( + ("web_server_config", "wifi_config", "expected"), + [ + # AP only: embed the interface, the AP has no internet. + ({CONF_VERSION: 2}, AP_ONLY, True), + ({CONF_VERSION: 3}, AP_ONLY, True), + # Explicit setting always wins. + ({CONF_VERSION: 2, CONF_LOCAL: False}, AP_ONLY, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, STA_ONLY, True), + # AP fallback, no AP, no wifi, or version 1 (no local mode): hosted page. + ({CONF_VERSION: 2}, AP_FALLBACK, False), + ({CONF_VERSION: 2}, STA_ONLY, False), + ({CONF_VERSION: 2}, None, False), + ({CONF_VERSION: 1}, AP_ONLY, False), + ], +) +def test_serve_local( + web_server_config: dict, wifi_config: dict | None, expected: bool +) -> None: + """The interface is embedded for AP only WiFi unless local is set explicitly.""" + assert serve_local(web_server_config, wifi_config) is expected + + +@pytest.mark.parametrize( + ("web_server_config", "full_config", "expected"), + [ + # AP only: local is implied, web_server is the captive portal. + ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, True), + # Captive portal probes only work on port 80. + ({CONF_VERSION: 2, CONF_PORT: 8080}, {CONF_WIFI: AP_ONLY}, False), + # AP fallback needs an explicit local: true to be captive. + ({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: AP_FALLBACK}, True), + # captive_portal owns the role when configured. + ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY, "captive_portal": {}}, False), + # No AP, no wifi, hosted page, or version 1: never captive. + ({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: STA_ONLY}, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, {}, False), + ({CONF_VERSION: 2, CONF_LOCAL: False}, {CONF_WIFI: AP_ONLY}, False), + ({CONF_VERSION: 1}, {CONF_WIFI: AP_ONLY}, False), + ], +) +def test_serve_captive( + web_server_config: dict, full_config: dict, expected: bool +) -> None: + web_server_config.setdefault(CONF_PORT, 80) + assert serve_captive(web_server_config, full_config) is expected + + +@pytest.mark.parametrize( + ("web_server_config", "expect_warning"), + [ + # Explicit local: false on an AP only device: the hosted page will stay blank. + ({CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: False}, True), + # Default: embedded and captive, nothing to warn about. + ({CONF_VERSION: 2, CONF_PORT: 80}, False), + ], +) +def test_final_validate_ap_mode_warns_for_hosted_page( + web_server_config: dict, expect_warning: bool, caplog: pytest.LogCaptureFixture +) -> None: + token = fv.full_config.set({"web_server": web_server_config, CONF_WIFI: AP_ONLY}) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(web_server_config) + finally: + fv.full_config.reset(token) + assert ("stays blank" in caplog.text) is expect_warning + + +@pytest.mark.parametrize( + ("web_server_config", "wifi_config", "expected"), + [ + # AP only: local is implied, so the port alone blocks captive mode. + ({CONF_VERSION: 2, CONF_PORT: 8080}, AP_ONLY, "on the access point"), + # Fallback AP with local: true opted into captive mode; say why it is skipped. + ( + {CONF_VERSION: 2, CONF_PORT: 8080, CONF_LOCAL: True}, + AP_FALLBACK, + "on the fallback access point", + ), + # Fallback AP without local: hosted page, nothing was promised. + ({CONF_VERSION: 2, CONF_PORT: 8080}, AP_FALLBACK, None), + ], +) +def test_final_validate_ap_mode_warns_for_non_default_port( + web_server_config: dict, + wifi_config: dict, + expected: str | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Captive portal detection needs port 80; other ports get a hint, not captive mode.""" + token = fv.full_config.set( + {"web_server": web_server_config, CONF_WIFI: wifi_config} + ) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(web_server_config) + finally: + fv.full_config.reset(token) + if expected is None: + assert "cannot open automatically" not in caplog.text + return + assert expected in caplog.text + assert "http://192.168.4.1:8080/" in caplog.text + + +def test_final_validate_ap_mode_port_warning_uses_manual_ip( + caplog: pytest.LogCaptureFixture, +) -> None: + """The manual URL in the port warning honors wifi.ap.manual_ip.""" + from esphome.const import CONF_MANUAL_IP, CONF_STATIC_IP + + config = {CONF_VERSION: 2, CONF_PORT: 8080} + wifi = {CONF_AP: {CONF_MANUAL_IP: {CONF_STATIC_IP: "10.0.0.1"}}} + token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi}) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(config) + finally: + fv.full_config.reset(token) + assert "http://10.0.0.1:8080/" in caplog.text + + +@pytest.mark.parametrize( + ("wifi_config", "expected"), + [ + # Explicit local: true on a fallback AP: announce the captive fallback role. + (AP_FALLBACK, "fallback access point"), + # Explicit local: true on AP only skips the implied-local info; still announce. + (AP_ONLY, "captive portal while the access point"), + ], +) +def test_final_validate_ap_mode_informs_explicit_local_captive( + wifi_config: dict, expected: str, caplog: pytest.LogCaptureFixture +) -> None: + """Explicit local: true logs that web_server becomes the captive portal.""" + config = {CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: True} + token = fv.full_config.set({"web_server": config, CONF_WIFI: wifi_config}) + try: + with caplog.at_level(logging.INFO): + _final_validate_ap_mode(config) + finally: + fv.full_config.reset(token) + assert expected in caplog.text From 9c35bb7426a10307292a015bb1058c9159e3bdbb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:48:33 -0400 Subject: [PATCH 196/268] [esp32] Check the TLS option the active IDF version writes in tests (#20584) --- tests/component_tests/esp32/test_esp32.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bb751dd677..bd8c099c09 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -31,6 +31,7 @@ from esphome.components.esp32 import ( _reconcile_vfs_fatfs_sdkconfig, _user_sdkconfig_wants_tls, allow_insecure_tls, + idf_version, ) from esphome.components.esp32.const import ( KEY_ESP32, @@ -981,6 +982,17 @@ def test_user_sdkconfig_wants_tls(options: dict[str, Any], wants_tls: bool) -> N assert _user_sdkconfig_wants_tls(options) is wants_tls +def _tls_compiled_out(sdkconfig: dict[str, Any]) -> bool: + """IDF 6 compiles TLS out with CONFIG_MBEDTLS_TLS_ENABLED=n; IDF 5 has no + prompt for that and uses CONFIG_MBEDTLS_TLS_DISABLED=y instead.""" + if idf_version() >= cv.Version(6, 0, 0): + value = sdkconfig.get("CONFIG_MBEDTLS_TLS_ENABLED") + if isinstance(value, RawSdkconfigValue): + return value.value == "n" + return value is False + return sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + + @pytest.mark.parametrize( ("config_file", "tls_off", "ecp_off", "esp_tls_excluded"), [ @@ -1034,7 +1046,7 @@ def test_tls_disabled_sdkconfig( """TLS is compiled out unless a component or a raw sdkconfig option asks for it.""" generate_main(component_config_path(config_file)) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert (sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True) is tls_off + assert _tls_compiled_out(sdkconfig) is tls_off assert sdkconfig.get("CONFIG_MBEDTLS_ECP_C") is (False if ecp_off else None) assert ( "esp-tls" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] @@ -2168,7 +2180,7 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( deterministic ECDSA are plain crypto, and OpenThread keeps those two.""" generate_main(component_config_path("mbedtls_tls_openthread.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + assert _tls_compiled_out(sdkconfig) # require_mbedtls_ecp() keeps ECP for the SRP host key while TLS is off assert "CONFIG_MBEDTLS_ECP_C" not in sdkconfig assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) @@ -2185,7 +2197,7 @@ def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( deterministic ECDSA.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] - assert sdkconfig.get("CONFIG_MBEDTLS_TLS_DISABLED") is True + assert _tls_compiled_out(sdkconfig) assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) From aa0bd9af72f6787a116ff56390a56672e3ea6727 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 07:55:38 -1000 Subject: [PATCH 197/268] [core] Add YAML secret reference and registration helpers (#20343) --- esphome/bundle.py | 20 ++--- esphome/yaml_util.py | 78 ++++++++++++++++++- tests/unit_tests/conftest.py | 13 ++++ tests/unit_tests/test_bundle.py | 11 ++- tests/unit_tests/test_yaml_util.py | 118 ++++++++++++++++++++++++++--- 5 files changed, 215 insertions(+), 25 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index b633c5ca4f..45d9a74b49 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -102,25 +102,21 @@ _KNOWN_FILE_EXTENSIONS = frozenset( ) -# Matches !secret references in YAML text. An optional surrounding -# quote pair around the key is allowed and ignored: YAML treats -# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is -# intentionally a simple regex scan rather than a YAML parse — it may -# match inside comments or multi-line strings, which is the conservative -# direction (include more secrets rather than fewer). -_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""") - - def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: """Scan YAML files for ``!secret `` references.""" keys: set[str] = set() for fpath in yaml_files: try: text = fpath.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): + except (OSError, UnicodeDecodeError) as err: + _LOGGER.warning( + "Could not scan %s for !secret references (%s); the bundled " + "secret set may be incomplete", + fpath, + err, + ) continue - for match in _SECRET_RE.finditer(text): - keys.add(match.group(1)) + keys |= yaml_util.find_secret_references(text) return keys diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index f550bb543b..5882c133f6 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -11,6 +11,7 @@ import logging import math import os from pathlib import Path +import re from typing import Any import uuid @@ -46,6 +47,12 @@ _LOGGER = logging.getLogger(__name__) SECRET_YAML = "secrets.yaml" _SECRET_CACHE = {} _SECRET_VALUES = {} +# Every !secret name the loader resolved; _SECRET_VALUES is keyed by value, so +# names sharing a value collide there. +_LOADED_SECRET_NAMES: set[str] = set() +# Set while a secret_values_registered context is active; the dumper records +# every emitted `!secret` name into it. +_EMITTED_SECRET_NAMES: set[str] | None = None # Not thread-safe — config processing is single-threaded today. _load_listeners: list[Callable[[Path], None]] = [] @@ -456,6 +463,16 @@ def _load_include_candidates( ) +# `!secret key`, quoted or not, also inside flow collections. A text scan, not a +# YAML parse, so it can also match comments; that errs toward more secrets. +_SECRET_REFERENCE_RE = re.compile(r"""!secret\s+['"]?([^\s'",\]}]+)""") + + +def find_secret_references(text: str) -> set[str]: + """Return the ``!secret `` names referenced in a YAML document text.""" + return {match.group(1) for match in _SECRET_REFERENCE_RE.finditer(text)} + + def force_load_include_files( obj: Any, *, @@ -858,6 +875,7 @@ class ESPHomeLoaderMixin: ) val = secrets[node.value] _SECRET_VALUES[str(val)] = node.value + _LOADED_SECRET_NAMES.add(node.value) return val @_add_data_ref @@ -1030,6 +1048,7 @@ def load_yaml( if clear_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() + _LOADED_SECRET_NAMES.clear() return _load_yaml_internal(fname, track_document_range=track_document_range) @@ -1135,6 +1154,38 @@ def _load_yaml_internal_with_type( loader.dispose() +def registered_secret_names() -> set[str]: + """Names of all ``!secret`` keys the loader has seen since the last clear.""" + return set(_LOADED_SECRET_NAMES) + + +@contextmanager +def secret_values_registered(values: dict[str, str]) -> Generator[set[str]]: + """Make :func:`dump` write these values as ``!secret `` while active. + + Real secrets already registered win. Yields the set of ``!secret`` names the + dumper emits. A wrapped value (``!extend``, ``!remove``, ``!include``) loses its + tag rather than leaking. + """ + global _EMITTED_SECRET_NAMES # noqa: PLW0603 + added = {v: n for v, n in values.items() if v not in _SECRET_VALUES} + _SECRET_VALUES.update(added) + outer = _EMITTED_SECRET_NAMES + emitted: set[str] = set() + _EMITTED_SECRET_NAMES = emitted + try: + yield emitted + finally: + _EMITTED_SECRET_NAMES = outer + if outer is not None: + outer |= emitted + # Only drop mappings this context still owns; a real !secret loaded + # meanwhile may have registered the same value under its own name. + for value, name in added.items(): + if _SECRET_VALUES.get(value) == name: + del _SECRET_VALUES[value] + + def dump( dict_, show_secrets=False, @@ -1152,6 +1203,7 @@ def dump( if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() + _LOADED_SECRET_NAMES.clear() # Per-call subclass so the flags don't leak across calls. # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML @@ -1349,7 +1401,10 @@ class ESPHomeDumper(yaml.SafeDumper): return node def represent_secret(self, value): - return self.represent_scalar(tag="!secret", value=_SECRET_VALUES[str(value)]) + name = _SECRET_VALUES[str(value)] + if _EMITTED_SECRET_NAMES is not None: + _EMITTED_SECRET_NAMES.add(name) + return self.represent_scalar(tag="!secret", value=name) def represent_stringify(self, value): if is_secret(value): @@ -1433,14 +1488,33 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value.value) return self.represent_scalar(tag="!lambda", value=value.value, style="|") + def _represent_tagged_secret(self, tag: str, value: Any) -> yaml.ScalarNode: + # Writing the secret beats leaking the value, but the tag is lost, so + # the dumped config no longer means the same thing when loaded back. + _LOGGER.warning( + "%s value matches secret '%s'; written as !secret, dropping the %s tag", + tag, + _SECRET_VALUES[str(value)], + tag, + ) + return self.represent_secret(value) + def represent_extend(self, value): + # Consult is_secret like the other scalar representers so a payload + # equal to a registered secret is never written out in cleartext. + if is_secret(value.value): + return self._represent_tagged_secret("!extend", value.value) return self.represent_scalar(tag="!extend", value=value.value) def represent_remove(self, value): + if is_secret(value.value): + return self._represent_tagged_secret("!remove", value.value) return self.represent_scalar(tag="!remove", value=value.value) def represent_include_file(self, value): if value.vars or value.condition is not None: + # The mapping values route through the regular representers, + # which already consult is_secret. mapping = {"file": value.file} if value.vars: mapping["vars"] = value.vars @@ -1449,6 +1523,8 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_mapping( tag="!include", mapping=mapping, flow_style=False ) + if is_secret(value.file): + return self._represent_tagged_secret("!include", value.file) return self.represent_scalar(tag="!include", value=value.file) def represent_id(self, value): diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 7835156b01..6bffc434ee 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -17,6 +17,7 @@ from unittest.mock import Mock, patch import pytest +from esphome import yaml_util from esphome.core import CORE from esphome.espidf import toolchain @@ -49,6 +50,18 @@ def reset_core(): CORE.reset() +@pytest.fixture(autouse=True) +def clear_yaml_secrets() -> Generator[None]: + """Isolate the yaml_util secrets registry between tests.""" + yaml_util._SECRET_VALUES.clear() + yaml_util._SECRET_CACHE.clear() + yaml_util._LOADED_SECRET_NAMES.clear() + yield + yaml_util._SECRET_VALUES.clear() + yaml_util._SECRET_CACHE.clear() + yaml_util._LOADED_SECRET_NAMES.clear() + + @pytest.fixture def fixture_path() -> Path: """ diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 8a2510cf0a..6a08676784 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -139,10 +139,17 @@ def test_find_used_secret_keys_no_secrets(tmp_path: Path) -> None: assert keys == set() -def test_find_used_secret_keys_missing_file(tmp_path: Path) -> None: +def test_find_used_secret_keys_missing_file( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: missing = tmp_path / "does_not_exist.yaml" - keys = _find_used_secret_keys([missing]) + with caplog.at_level("WARNING", logger="esphome.bundle"): + keys = _find_used_secret_keys([missing]) assert keys == set() + assert any( + "Could not scan" in r.message and "does_not_exist.yaml" in r.message + for r in caplog.records + ) def test_find_used_secret_keys_deduplicates(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 60b05b0b2b..24a431b0c0 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -26,16 +26,6 @@ from esphome.yaml_util import ( ) -@pytest.fixture(autouse=True) -def clear_secrets_cache() -> None: - """Clear the secrets cache before each test.""" - yaml_util._SECRET_VALUES.clear() - yaml_util._SECRET_CACHE.clear() - yield - yaml_util._SECRET_VALUES.clear() - yaml_util._SECRET_CACHE.clear() - - @pytest.fixture(autouse=True) def clear_core_frontmatter() -> None: """Reset CORE.frontmatter between tests.""" @@ -1973,6 +1963,72 @@ def test_dump__redaction_flag_does_not_leak_between_calls() -> None: assert "\\033[8m" in redacted_again +def test_secret_values_registered_swaps_scalars_in_dump() -> None: + """Registered value→name mappings make dump() emit `!secret ` for + matching scalars, and are removed again on exit.""" + with yaml_util.secret_values_registered({"hunter2": "wifi_password"}): + out = yaml_util.dump({"password": make_data_base("hunter2")}) + assert "password: !secret 'wifi_password'" in out + assert "hunter2" not in out + out_after = yaml_util.dump({"password": make_data_base("hunter2")}) + assert "hunter2" in out_after + assert "!secret" not in out_after + assert yaml_util.is_secret("hunter2") is None + + +def test_secret_values_registered_collects_emitted_names() -> None: + """The context yields a set recording exactly the `!secret` names the + dumper emitted, including real secrets, and not names never swapped.""" + yaml_util._SECRET_VALUES["real_value"] = "real_name" + with yaml_util.secret_values_registered({"hunter2": "wifi_password"}) as emitted: + yaml_util.dump( + { + "password": make_data_base("hunter2"), + "key": make_data_base("real_value"), + "plain": make_data_base("nothing"), + } + ) + assert emitted == {"wifi_password", "real_name"} + + +def test_secret_values_registered_does_not_clobber_real_secrets() -> None: + """A value already mapped by a real `!secret` keeps its original name.""" + yaml_util._SECRET_VALUES["hunter2"] = "original_name" + with yaml_util.secret_values_registered({"hunter2": "generated_name"}): + out = yaml_util.dump({"password": make_data_base("hunter2")}) + assert "!secret 'original_name'" in out + # The pre-existing mapping survives the context exit. + assert yaml_util.is_secret("hunter2") == "original_name" + + +def test_secret_values_registered_keeps_a_real_secret_loaded_meanwhile() -> None: + """A real `!secret` registered inside the context survives its exit.""" + with yaml_util.secret_values_registered({"hunter2": "generated_name"}): + yaml_util._SECRET_VALUES["hunter2"] = "real_name" + assert yaml_util.is_secret("hunter2") == "real_name" + + +def test_secret_values_registered_nested_keeps_the_outer_collector() -> None: + """The outer context also collects names emitted inside an inner one.""" + with yaml_util.secret_values_registered({"outer_value": "outer"}) as outer: + with yaml_util.secret_values_registered({"inner_value": "inner"}) as inner: + yaml_util.dump({"a": make_data_base("inner_value")}) + yaml_util.dump({"b": make_data_base("outer_value")}) + assert inner == {"inner"} + assert outer == {"inner", "outer"} + + +def test_registered_secret_names(tmp_path: Path) -> None: + """Every loaded name is returned, even two sharing a value, but not names + registered only for a dump.""" + (tmp_path / "secrets.yaml").write_text("wifi_password: same\nap_password: same\n") + main = tmp_path / "main.yaml" + main.write_text("a: !secret wifi_password\nb: !secret ap_password\n") + yaml_util.load_yaml(main) + with yaml_util.secret_values_registered({"other": "temp_name"}): + assert yaml_util.registered_secret_names() == {"wifi_password", "ap_password"} + + @pytest.fixture(autouse=True) def clear_dropped_merge_keys() -> None: """Reset the dropped-merge-key queue between tests.""" @@ -2018,6 +2074,42 @@ def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: assert yaml_util.take_dropped_merge_keys() == [] +def test_wrapper_representers_consult_is_secret( + caplog: pytest.LogCaptureFixture, +) -> None: + """!extend / !remove payloads and scalar !include paths equal to a + registered secret are swapped, never written in cleartext, and the lost + tag is logged.""" + from esphome.config_helpers import Extend, Remove + + with ( + caplog.at_level("WARNING", logger="esphome.yaml_util"), + yaml_util.secret_values_registered({"hunter2": "the_secret"}), + ): + out = yaml_util.dump( + { + "a": Extend("hunter2"), + "b": Remove("hunter2"), + "c": Extend("plain_id"), + } + ) + assert out.count("!secret 'the_secret'") == 2 + assert "hunter2" not in out + assert "!extend 'plain_id'" in out + lost = [r.message for r in caplog.records if "dropping the" in r.message] + assert len(lost) == 2 + assert all("hunter2" not in m for m in lost) + + +def test_scalar_include_path_equal_to_secret_is_swapped() -> None: + """A scalar !include whose path equals a registered secret is swapped, + matching the other wrapper representers.""" + include = yaml_util.IncludeFile(Path("/fake/main.yaml"), "hunter2", lambda _: {}) + with yaml_util.secret_values_registered({"hunter2": "the_secret"}): + out = yaml_util.dump({"key": include}) + assert out == "key: !secret 'the_secret'\n" + + # --------------------------------------------------------------------------- # track_document_range=False (validated-config-cache fast path) # --------------------------------------------------------------------------- @@ -2135,3 +2227,9 @@ def test_load_yaml_fast_mode_records_dropped_merge_keys( yaml_util.load_yaml(yaml_file, track_document_range=False) assert yaml_util.take_dropped_merge_keys() == [("port", str(yaml_file))] + + +def test_find_secret_references_in_flow_collections() -> None: + """Flow mapping and sequence punctuation is not part of the secret name.""" + text = "a: {password: !secret wifi_pw}\nb: [!secret one, !secret 'two']\n" + assert yaml_util.find_secret_references(text) == {"wifi_pw", "one", "two"} From ca69c33530f88ceb0f26c9da7bf6f290f4c1502a Mon Sep 17 00:00:00 2001 From: arpena <15361812+arpena@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:57:23 -0300 Subject: [PATCH 198/268] [whirlpool] Add the HISENSE_ARG model for Hisense and BGH units sold in Argentina (#17352) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/whirlpool/climate.py | 6 +- esphome/components/whirlpool/whirlpool.cpp | 108 ++++- esphome/components/whirlpool/whirlpool.h | 19 +- esphome/core/defines.h | 1 + tests/component_tests/whirlpool/__init__.py | 0 .../whirlpool/test_model_define.py | 27 ++ .../whirlpool/test_model_define.yaml | 16 + .../whirlpool/test_model_define_dg11j1.yaml | 16 + tests/components/whirlpool/__init__.py | 16 + tests/components/whirlpool/benchmark.yaml | 4 + tests/components/whirlpool/common-dg11j1.yaml | 5 + tests/components/whirlpool/common.yaml | 6 + .../whirlpool/test-dg11j1.esp32-idf.yaml | 3 + .../components/whirlpool/test.esp32-idf.yaml | 4 +- .../whirlpool/test.esp8266-ard.yaml | 4 +- .../components/whirlpool/whirlpool_tests.cpp | 392 ++++++++++++++++++ 17 files changed, 603 insertions(+), 26 deletions(-) create mode 100644 tests/component_tests/whirlpool/__init__.py create mode 100644 tests/component_tests/whirlpool/test_model_define.py create mode 100644 tests/component_tests/whirlpool/test_model_define.yaml create mode 100644 tests/component_tests/whirlpool/test_model_define_dg11j1.yaml create mode 100644 tests/components/whirlpool/__init__.py create mode 100644 tests/components/whirlpool/benchmark.yaml create mode 100644 tests/components/whirlpool/common-dg11j1.yaml create mode 100644 tests/components/whirlpool/test-dg11j1.esp32-idf.yaml create mode 100644 tests/components/whirlpool/whirlpool_tests.cpp diff --git a/CODEOWNERS b/CODEOWNERS index c75693d2d3..7e263a4514 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -638,7 +638,7 @@ esphome/components/web_server_idf/* @dentra esphome/components/weikai/* @DrCoolZic esphome/components/weikai_i2c/* @DrCoolZic esphome/components/weikai_spi/* @DrCoolZic -esphome/components/whirlpool/* @glmnet +esphome/components/whirlpool/* @arpena @glmnet esphome/components/whynter/* @aeonsablaze esphome/components/wiegand/* @ssieb esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard diff --git a/esphome/components/whirlpool/climate.py b/esphome/components/whirlpool/climate.py index 3435a6c0bf..efc7e6ca57 100644 --- a/esphome/components/whirlpool/climate.py +++ b/esphome/components/whirlpool/climate.py @@ -5,7 +5,7 @@ from esphome.const import CONF_MODEL from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] -CODEOWNERS = ["@glmnet"] +CODEOWNERS = ["@glmnet", "@arpena"] whirlpool_ns = cg.esphome_ns.namespace("whirlpool") WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR) @@ -14,6 +14,7 @@ Model = whirlpool_ns.enum("Model") MODELS = { "DG11J1-3A": Model.MODEL_DG11J1_3A, "DG11J1-91": Model.MODEL_DG11J1_91, + "HISENSE_ARG": Model.MODEL_HISENSE_ARG, } CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend( @@ -26,3 +27,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).ext async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_model(config[CONF_MODEL])) + # cv.enum keeps the YAML key as a str subclass; the MockObj value cannot be compared + if config[CONF_MODEL] == "HISENSE_ARG": + cg.add_define("USE_WHIRLPOOL_HISENSE_ARG") diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index 73fab843fc..e341ce04bb 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -27,10 +27,20 @@ const uint8_t WHIRLPOOL_FAN_HIGH = 1; const uint8_t WHIRLPOOL_FAN_MED = 2; const uint8_t WHIRLPOOL_FAN_LOW = 3; -const uint8_t WHIRLPOOL_SWING_MASK = 128; +const uint8_t WHIRLPOOL_FAN_MASK = 0x03; + +const uint8_t WHIRLPOOL_SWING_MASK = 128; // byte 2 +const uint8_t WHIRLPOOL_SWING_BYTE8 = 0x40; // byte 8 const uint8_t WHIRLPOOL_POWER = 0x04; +// Hisense ARG model; unused constexpr values cost nothing in DG11J1-only builds +static constexpr uint8_t HISENSE_ARG_DISPLAY_ON = 0x80; // byte 11 +static constexpr uint8_t HISENSE_ARG_AMBIENT_TEMP = 0x18; // byte 12, 24 C +static constexpr uint8_t HISENSE_ARG_POWER_TOGGLE = 0x01; // byte 15 +static constexpr uint8_t HISENSE_ARG_SETTINGS_ONLY = 0x02; // byte 15 +static constexpr uint8_t HISENSE_ARG_FIXED_TEMP_STEP = 7; // auto and fan-only send 23 C + WhirlpoolClimate::WhirlpoolClimate() : climate_ir::ClimateIR( WHIRLPOOL_DG11J1_3A_TEMP_MIN, WHIRLPOOL_DG11J1_3A_TEMP_MAX, 1.0f, true, true, @@ -44,11 +54,11 @@ void WhirlpoolClimate::transmit_state() { remote_state[0] = 0x83; remote_state[1] = 0x06; remote_state[6] = 0x80; - // MODEL DG11J191 - remote_state[18] = 0x08; + remote_state[18] = 0x08; // the same on every supported model auto powered_on = this->mode != climate::CLIMATE_MODE_OFF; - if (powered_on != this->powered_on_assumed) { + const bool power_toggle = powered_on != this->powered_on_assumed; + if (power_toggle) { // Set power toggle command remote_state[2] = 4; remote_state[15] = 1; @@ -83,9 +93,16 @@ void WhirlpoolClimate::transmit_state() { break; } + const bool hisense_arg = this->is_hisense_arg_(); + // Temperature auto temp = (uint8_t) roundf(clamp(this->target_temperature, this->temperature_min_(), this->temperature_max_())); - remote_state[3] |= (uint8_t) (temp - this->temperature_min_()) << 4; + const auto temp_step = (uint8_t) (temp - this->temperature_min_()); + if (hisense_arg && this->fixed_temp_mode_()) { + remote_state[3] |= HISENSE_ARG_FIXED_TEMP_STEP << 4; + } else { + remote_state[3] |= temp_step << 4; + } // Fan speed switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { @@ -104,10 +121,17 @@ void WhirlpoolClimate::transmit_state() { // Swing ESP_LOGV(TAG, "send swing %s", this->send_swing_cmd_ ? LOG_STR_LITERAL("true") : LOG_STR_LITERAL("false")); - if (this->send_swing_cmd_) { + if (hisense_arg) { + // This model sends swing as a state on every frame; the DG11J1 remotes send a toggle + if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL) { + remote_state[2] |= WHIRLPOOL_SWING_MASK; + remote_state[8] = WHIRLPOOL_SWING_BYTE8; + } + this->encode_hisense_arg_(remote_state, power_toggle, temp_step); + } else if (this->send_swing_cmd_) { if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL || this->swing_mode == climate::CLIMATE_SWING_OFF) { - remote_state[2] |= 128; - remote_state[8] |= 64; + remote_state[2] |= WHIRLPOOL_SWING_MASK; + remote_state[8] |= WHIRLPOOL_SWING_BYTE8; } } @@ -155,6 +179,49 @@ void WhirlpoolClimate::transmit_state() { transmit.perform(); } +bool WhirlpoolClimate::fixed_temp_mode_() const { + return this->mode == climate::CLIMATE_MODE_HEAT_COOL || this->mode == climate::CLIMATE_MODE_FAN_ONLY; +} + +void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const { +#ifdef USE_WHIRLPOOL_HISENSE_ARG + remote_state[11] = HISENSE_ARG_DISPLAY_ON; + remote_state[12] = HISENSE_ARG_AMBIENT_TEMP; + remote_state[15] = power_toggle ? HISENSE_ARG_POWER_TOGGLE : HISENSE_ARG_SETTINGS_ONLY; + + switch (this->mode) { + case climate::CLIMATE_MODE_OFF: + // The remote keeps the cool code in the power-off frame + remote_state[3] = (remote_state[3] & 0xF0) | WHIRLPOOL_COOL; + break; + case climate::CLIMATE_MODE_DRY: + // The unit runs these fan speeds itself; the user's choice is kept for the next mode change + remote_state[2] &= ~WHIRLPOOL_FAN_MASK; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + if ((remote_state[2] & WHIRLPOOL_FAN_MASK) == WHIRLPOOL_FAN_AUTO) { + remote_state[2] |= WHIRLPOOL_FAN_MED; + } + break; + case climate::CLIMATE_MODE_HEAT_COOL: + // Auto sends the target as one of four range codes in byte 2 bits 6:4 instead of the temperature nibble. + // Taken from the HPA-4911 firmware: 16 to 21 C, 22 C, 24 C, and everything else (23 C and 25 C up) share a code. + if (temp_step < 6) { + remote_state[2] |= 0x30; + } else if (temp_step == 6) { + remote_state[2] |= 0x50; + } else if (temp_step == 8) { + remote_state[2] |= 0x40; + } else { + remote_state[2] |= 0x20; + } + break; + default: + break; + } +#endif +} + bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { // Check if the esp isn't currently transmitting. if (millis() - this->last_transmit_time_ < 500) { @@ -265,16 +332,19 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { } } - // Set received temp - int temp = remote_state[3] & 0xF0; - ESP_LOGVV(TAG, "Temperature Raw: %02X", temp); - temp = (uint8_t) temp >> 4; - temp += static_cast(this->temperature_min_()); - ESP_LOGVV(TAG, "Temperature Climate: %u", temp); - this->target_temperature = temp; + // Set received temp; Hisense ARG sends fixed values in auto and fan-only + const bool hisense_arg = this->is_hisense_arg_(); + if (!hisense_arg || !this->fixed_temp_mode_()) { + int temp = remote_state[3] & 0xF0; + ESP_LOGVV(TAG, "Temperature Raw: %02X", temp); + temp = (uint8_t) temp >> 4; + temp += static_cast(this->temperature_min_()); + ESP_LOGVV(TAG, "Temperature Climate: %u", temp); + this->target_temperature = temp; + } // Set received fan speed - auto fan = remote_state[2] & 0x03; + auto fan = remote_state[2] & WHIRLPOOL_FAN_MASK; ESP_LOGVV(TAG, "Fan: %02X", fan); switch (fan) { case WHIRLPOOL_FAN_HIGH: @@ -293,7 +363,11 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { } // Set received swing status - if ((remote_state[2] & WHIRLPOOL_SWING_MASK) == WHIRLPOOL_SWING_MASK && remote_state[8] == 0x40) { + if (hisense_arg) { + this->swing_mode = + (remote_state[8] & WHIRLPOOL_SWING_BYTE8) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + } else if ((remote_state[2] & WHIRLPOOL_SWING_MASK) == WHIRLPOOL_SWING_MASK && + remote_state[8] == WHIRLPOOL_SWING_BYTE8) { ESP_LOGVV(TAG, "Swing toggle pressed "); if (this->swing_mode == climate::CLIMATE_SWING_OFF) { this->swing_mode = climate::CLIMATE_SWING_VERTICAL; diff --git a/esphome/components/whirlpool/whirlpool.h b/esphome/components/whirlpool/whirlpool.h index b705ee95fa..8b5fc7519a 100644 --- a/esphome/components/whirlpool/whirlpool.h +++ b/esphome/components/whirlpool/whirlpool.h @@ -6,8 +6,9 @@ namespace esphome::whirlpool { /// Simple enum to represent models. enum Model { - MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32 - MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30 + MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32 + MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30 + MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30 }; // Temperature @@ -46,10 +47,22 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR { void transmit_state() override; /// Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; + /// False when the model is compiled out, so the compiler drops the Hisense ARG branches. + bool is_hisense_arg_() const { +#ifdef USE_WHIRLPOOL_HISENSE_ARG + return this->model_ == MODEL_HISENSE_ARG; +#else + return false; +#endif + } + /// Modes in which the Hisense ARG model sends a fixed temperature. + bool fixed_temp_mode_() const; + /// Add the Hisense ARG bytes on top of the common whirlpool frame. + void encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const; /// Set the time of the last transmission. uint32_t last_transmit_time_{}; - bool send_swing_cmd_{false}; + bool send_swing_cmd_{false}; // the DG11J1 remotes send swing as a toggle, only on the call that changed it Model model_; float temperature_min_() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5ae701c552..628435a4d8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -265,6 +265,7 @@ #define USE_VALVE #define USE_WATER_HEATER #define USE_WATER_HEATER_VISUAL_OVERRIDES +#define USE_WHIRLPOOL_HISENSE_ARG #define USE_ZWAVE_PROXY // Feature flags which do not work for zephyr diff --git a/tests/component_tests/whirlpool/__init__.py b/tests/component_tests/whirlpool/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/whirlpool/test_model_define.py b/tests/component_tests/whirlpool/test_model_define.py new file mode 100644 index 0000000000..1f6840f300 --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define.py @@ -0,0 +1,27 @@ +"""The Hisense ARG code is compiled in only when that model is configured.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +DEFINE = "USE_WHIRLPOOL_HISENSE_ARG" +HERE = Path(__file__).parent + + +def _define_names() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_hisense_arg_model_emits_the_define( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(HERE / "test_model_define.yaml") + assert DEFINE in _define_names() + + +def test_dg11j1_model_does_not_emit_the_define( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(HERE / "test_model_define_dg11j1.yaml") + assert DEFINE not in _define_names() diff --git a/tests/component_tests/whirlpool/test_model_define.yaml b/tests/component_tests/whirlpool/test_model_define.yaml new file mode 100644 index 0000000000..80c0bc142b --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_transmitter: + id: xmitr + pin: GPIO4 + carrier_duty_percent: 50% + +climate: + - platform: whirlpool + name: Hisense + model: HISENSE_ARG + transmitter_id: xmitr diff --git a/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml b/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml new file mode 100644 index 0000000000..37a8036265 --- /dev/null +++ b/tests/component_tests/whirlpool/test_model_define_dg11j1.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + board: esp32dev + +remote_transmitter: + id: xmitr + pin: GPIO4 + carrier_duty_percent: 50% + +climate: + - platform: whirlpool + name: Whirlpool + model: DG11J1-91 + transmitter_id: xmitr diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py new file mode 100644 index 0000000000..badd604322 --- /dev/null +++ b/tests/components/whirlpool/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +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: ConfigType) -> None: + # Compiles in the Hisense ARG model, which climate.py adds only when it is configured + cg.add_define("USE_WHIRLPOOL_HISENSE_ARG") + + manifest.to_code = to_code_testing diff --git a/tests/components/whirlpool/benchmark.yaml b/tests/components/whirlpool/benchmark.yaml new file mode 100644 index 0000000000..7f0bd4e07c --- /dev/null +++ b/tests/components/whirlpool/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The whirlpool, 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/whirlpool/common-dg11j1.yaml b/tests/components/whirlpool/common-dg11j1.yaml new file mode 100644 index 0000000000..4c0280c9ff --- /dev/null +++ b/tests/components/whirlpool/common-dg11j1.yaml @@ -0,0 +1,5 @@ +climate: + - platform: whirlpool + name: Whirlpool DG11J1-91 Climate + model: DG11J1-91 + transmitter_id: xmitr diff --git a/tests/components/whirlpool/common.yaml b/tests/components/whirlpool/common.yaml index 6d55db1f08..8551f78c26 100644 --- a/tests/components/whirlpool/common.yaml +++ b/tests/components/whirlpool/common.yaml @@ -2,3 +2,9 @@ climate: - platform: whirlpool name: Whirlpool Climate transmitter_id: xmitr + - platform: whirlpool + id: whirlpool_hisense_arg + name: Whirlpool Hisense ARG Climate + model: HISENSE_ARG + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml b/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml new file mode 100644 index 0000000000..7d6fa086ef --- /dev/null +++ b/tests/components/whirlpool/test-dg11j1.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + whirlpool: !include common-dg11j1.yaml diff --git a/tests/components/whirlpool/test.esp32-idf.yaml b/tests/components/whirlpool/test.esp32-idf.yaml index e891f9dc85..39e62c0664 100644 --- a/tests/components/whirlpool/test.esp32-idf.yaml +++ b/tests/components/whirlpool/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + whirlpool: !include common.yaml diff --git a/tests/components/whirlpool/test.esp8266-ard.yaml b/tests/components/whirlpool/test.esp8266-ard.yaml index 4bed2f03e5..6e2dfff6dd 100644 --- a/tests/components/whirlpool/test.esp8266-ard.yaml +++ b/tests/components/whirlpool/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 + whirlpool: !include common.yaml diff --git a/tests/components/whirlpool/whirlpool_tests.cpp b/tests/components/whirlpool/whirlpool_tests.cpp new file mode 100644 index 0000000000..ef628abe54 --- /dev/null +++ b/tests/components/whirlpool/whirlpool_tests.cpp @@ -0,0 +1,392 @@ +#include +#include +#include +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/whirlpool/whirlpool.h" +#include "esphome/core/preferences.h" + +namespace esphome::whirlpool::testing { + +static constexpr size_t FRAME_LEN = 21; +using Frame = std::array; + +static constexpr int32_t HEADER_MARK = 9000; +static constexpr int32_t HEADER_SPACE = 4494; +static constexpr int32_t BIT_MARK = 572; +static constexpr int32_t ONE_SPACE = 1659; +static constexpr int32_t ZERO_SPACE = 553; +static constexpr int32_t GAP_SPACE = 7960; + +static bool divider_after(size_t bytes_sent) { return bytes_sent == 6 || bytes_sent == 14; } + +/// The waveform for a frame: header, bytes LSB first, a divider after bytes 6 and 14, final mark +static remote_base::RawTimings waveform(const Frame &bytes) { + remote_base::RawTimings raw; + raw.push_back(HEADER_MARK); + raw.push_back(-HEADER_SPACE); + for (size_t i = 0; i < FRAME_LEN; i++) { + for (int bit = 0; bit < 8; bit++) { + raw.push_back(BIT_MARK); + raw.push_back((bytes[i] >> bit) & 1 ? -ONE_SPACE : -ZERO_SPACE); + } + if (divider_after(i + 1)) { + raw.push_back(BIT_MARK); + raw.push_back(-GAP_SPACE); + } + } + raw.push_back(BIT_MARK); + return raw; +} + +/// Decodes a captured waveform back into bytes, checking header, dividers and footer positions +static ::testing::AssertionResult decode(const remote_base::RawTimings &raw, Frame &out) { + static constexpr size_t EXPECTED_LEN = 2 + FRAME_LEN * 16 + 2 * 2 + 1; + if (raw.size() != EXPECTED_LEN) + return ::testing::AssertionFailure() << "timing count " << raw.size() << ", expected " << EXPECTED_LEN; + if (raw[0] != HEADER_MARK || raw[1] != -HEADER_SPACE) + return ::testing::AssertionFailure() << "bad header " << raw[0] << " " << raw[1]; + size_t pos = 2; + for (size_t i = 0; i < FRAME_LEN; i++) { + uint8_t value = 0; + for (int bit = 0; bit < 8; bit++) { + if (raw[pos] != BIT_MARK) + return ::testing::AssertionFailure() << "byte " << i << " bit " << bit << " mark " << raw[pos]; + if (raw[pos + 1] == -ONE_SPACE) { + value |= 1 << bit; + } else if (raw[pos + 1] != -ZERO_SPACE) { + return ::testing::AssertionFailure() << "byte " << i << " bit " << bit << " space " << raw[pos + 1]; + } + pos += 2; + } + out[i] = value; + if (divider_after(i + 1)) { + if (raw[pos] != BIT_MARK || raw[pos + 1] != -GAP_SPACE) + return ::testing::AssertionFailure() << "missing divider after byte " << i + 1; + pos += 2; + } + } + if (raw[pos] != BIT_MARK) + return ::testing::AssertionFailure() << "bad footer " << raw[pos]; + return ::testing::AssertionSuccess(); +} + +static uint8_t xor_range(const Frame &f, size_t from, size_t to) { + uint8_t x = 0; + for (size_t i = from; i < to; i++) + x ^= f[i]; + return x; +} + +static ::testing::AssertionResult checksums_ok(const Frame &f) { + if (xor_range(f, 2, 13) != f[13]) + return ::testing::AssertionFailure() << "byte 13 checksum"; + if (xor_range(f, 14, 20) != f[20]) + return ::testing::AssertionFailure() << "byte 20 checksum"; + return ::testing::AssertionSuccess(); +} + +/// Fills in both checksums of a frame +static Frame with_checksums(Frame f) { + f[13] = xor_range(f, 2, 13); + f[20] = xor_range(f, 14, 20); + return f; +} + +static std::string hex(const Frame &f) { + std::string out; + for (uint8_t b : f) { + char buf[4]; + snprintf(buf, sizeof(buf), "%02X ", b); + 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()); + } +}; + +struct Fixture { + CapturingTransmitter transmitter; + WhirlpoolClimate climate; + + explicit Fixture(Model model) { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + 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(); + } + + /// Decodes the last transmitted frame, failing the test if it is malformed or has bad checksums + Frame last_frame() const { + Frame f{}; + EXPECT_FALSE(this->transmitter.frames.empty()); + if (this->transmitter.frames.empty()) + return f; + EXPECT_TRUE(decode(this->transmitter.frames.back(), f)); + EXPECT_TRUE(checksums_ok(f)) << hex(f); + return f; + } +}; + +static void expect_frame(const Frame &actual, const Frame &expected) { + EXPECT_EQ(actual, expected) << "actual " << hex(actual) << "\nexpected " << hex(expected); +} + +// The receive path ignores frames for 500 ms after a transmit, so these run on a fixture that has not transmitted +static bool receive(WhirlpoolClimate &climate, const Frame &bytes) { + const remote_base::RawTimings raw = waveform(bytes); + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + return static_cast(climate).on_receive(data); +} + +TEST(WhirlpoolDG11J13A, CoolFromOffMatchesKnownFrame) { + Fixture f(MODEL_DG11J1_3A); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW); + + // byte 2: power toggle 0x04 | fan low 3; byte 3: (24 - 18) << 4 | cool 2; byte 15: cool overwrites toggle with 6 + expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x62, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xE5, 0x00, 0x06, 0x00, 0x00, 0x08, 0x00, 0x0E}); +} + +TEST(WhirlpoolDG11J191, CoolFromOffUsesSixteenDegreeBase) { + Fixture f(MODEL_DG11J1_91); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW); + + // byte 3: (24 - 16) << 4 | cool 2 + expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x05, 0x00, 0x06, 0x00, 0x00, 0x08, 0x00, 0x0E}); +} + +TEST(WhirlpoolHisenseArg, PowerToggleThenSettingsOnly) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF); + // byte 11 display on, byte 12 ambient 24 C, byte 15 power toggle + expect_frame(f.last_frame(), {0x83, 0x06, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x80, 0x18, 0x9D, 0x00, 0x01, 0x00, 0x00, 0x08, 0x00, 0x09}); + + // Only the fan changed: no power bit, byte 15 settings only + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_MEDIUM); + expect_frame(f.last_frame(), {0x83, 0x06, 0x02, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x80, 0x18, 0x98, 0x00, 0x02, 0x00, 0x00, 0x08, 0x00, 0x0A}); +} + +TEST(WhirlpoolHisenseArg, ModeSpecificFanAndTemperature) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW); + + f.command(climate::CLIMATE_MODE_DRY, 24, climate::CLIMATE_FAN_HIGH); + Frame frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x03, 0) << "dry forces fan auto"; + EXPECT_EQ(frame[3], 0x83); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_HIGH) << "the user's choice survives the dry frame"; + + f.command(climate::CLIMATE_MODE_FAN_ONLY, 20, climate::CLIMATE_FAN_AUTO); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x03, 2) << "fan-only with fan auto sends medium"; + EXPECT_EQ(frame[3], 0x74) << "fixed temperature nibble 7, fan mode 4"; + + f.command(climate::CLIMATE_MODE_FAN_ONLY, 20, climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(f.last_frame()[2] & 0x03, 1); + + struct RangeCase { + float target; + uint8_t range; + }; + // 23 C (step 7) shares the catch-all code with the high end of the range + for (const RangeCase &c : + {RangeCase{16, 0x30}, RangeCase{22, 0x50}, RangeCase{23, 0x20}, RangeCase{24, 0x40}, RangeCase{30, 0x20}}) { + f.command(climate::CLIMATE_MODE_HEAT_COOL, c.target, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[3], 0x71) << "target " << c.target; + EXPECT_EQ(frame[2] & 0x70, c.range) << "target " << c.target; + EXPECT_EQ(frame[2] & 0x03, 3) << "target " << c.target; + EXPECT_EQ(frame[15], 0x02) << "target " << c.target; + } +} + +TEST(WhirlpoolHisenseArg, SwingIsSentOnEveryTransmit) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL); + Frame frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0x80); + EXPECT_EQ(frame[8], 0x40); + + // A later command that does not mention swing still carries it + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0x80); + EXPECT_EQ(frame[8], 0x40); + + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0); + EXPECT_EQ(frame[8], 0x00); + + f.command(climate::CLIMATE_MODE_COOL, 26, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0); + EXPECT_EQ(frame[8], 0x00); +} + +TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) { + Fixture f(MODEL_DG11J1_3A); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL); + Frame frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0x80); + EXPECT_EQ(frame[8], 0x40); + + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW); + frame = f.last_frame(); + EXPECT_EQ(frame[2] & 0x80, 0); + EXPECT_EQ(frame[8], 0x00); +} + +// Cool 24 C (step 8), fan low, powered on assumed +static Frame hisense_cool_frame() { + return with_checksums({0x83, 0x06, 0x03, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x80, 0x18, 0x00, 0x00, 0x02, 0x00, 0x00, 0x08, 0x00, 0x00}); +} + +static void assume_on(WhirlpoolClimate &climate, climate::ClimateMode mode, float target) { + climate.mode = mode; + climate.target_temperature = target; + climate.powered_on_assumed = true; +} + +TEST(WhirlpoolHisenseArg, ReceiveSwingIsAbsolute) { + Fixture f(MODEL_HISENSE_ARG); + assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20); + + Frame frame = hisense_cool_frame(); + frame[8] = 0x40; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + // The same frame again keeps vertical; the DG11J1 path would toggle back + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + frame[8] = 0x00; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(WhirlpoolHisenseArg, ReceiveTemperature) { + Fixture f(MODEL_HISENSE_ARG); + assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20); + + EXPECT_TRUE(receive(f.climate, hisense_cool_frame())); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + + // Heat-cool carries a fixed temperature nibble, which must not overwrite the target + Frame frame = hisense_cool_frame(); + frame[2] = 0x43; + frame[3] = 0x71; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); + + // Fan-only likewise + frame[2] = 0x02; + frame[3] = 0x74; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24); +} + +TEST(WhirlpoolHisenseArg, ReceivePowerBitTogglesAssumption) { + Fixture f(MODEL_HISENSE_ARG); + ASSERT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + ASSERT_FALSE(f.climate.powered_on_assumed); + + Frame frame = hisense_cool_frame(); + frame[2] |= 0x04; + frame[15] = 0x01; + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_TRUE(f.climate.powered_on_assumed); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + + EXPECT_TRUE(receive(f.climate, with_checksums(frame))); + EXPECT_FALSE(f.climate.powered_on_assumed); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); +} + +TEST(WhirlpoolHisenseArg, ReceiveRejectsBadChecksum) { + Fixture f(MODEL_HISENSE_ARG); + assume_on(f.climate, climate::CLIMATE_MODE_COOL, 20); + + Frame frame = hisense_cool_frame(); + frame[13] ^= 0x01; + EXPECT_FALSE(receive(f.climate, frame)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20); + + frame = hisense_cool_frame(); + frame[20] ^= 0x01; + EXPECT_FALSE(receive(f.climate, frame)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20); +} + +TEST(WhirlpoolHisenseArg, ReceiveIgnoredRightAfterTransmit) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_COOL, 20, climate::CLIMATE_FAN_LOW); + EXPECT_FALSE(receive(f.climate, hisense_cool_frame())); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20); +} + +TEST(WhirlpoolHisenseArg, PowerOffKeepsTheCoolModeCode) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW); + f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW); + const Frame frame = f.last_frame(); + // The hardware-tested remote sends the cool code with the power toggle, not the heat code 0 + EXPECT_EQ(frame[3] & 0x07, 2); + EXPECT_EQ(frame[2] & 0x04, 0x04); + EXPECT_EQ(frame[15], 0x01); +} + +TEST(WhirlpoolHisenseArg, EveryTransmitHasValidChecksums) { + Fixture f(MODEL_HISENSE_ARG); + f.command(climate::CLIMATE_MODE_HEAT, 30, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + f.command(climate::CLIMATE_MODE_HEAT_COOL, 22, climate::CLIMATE_FAN_AUTO); + f.command(climate::CLIMATE_MODE_DRY, 18, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + f.command(climate::CLIMATE_MODE_FAN_ONLY, 25, climate::CLIMATE_FAN_LOW); + f.command(climate::CLIMATE_MODE_OFF, 25, climate::CLIMATE_FAN_LOW); + ASSERT_EQ(f.transmitter.frames.size(), 5u); + for (const auto &raw : f.transmitter.frames) { + Frame frame{}; + ASSERT_TRUE(decode(raw, frame)); + EXPECT_TRUE(checksums_ok(frame)) << hex(frame); + EXPECT_EQ(frame[0], 0x83); + EXPECT_EQ(frame[1], 0x06); + EXPECT_EQ(frame[11], 0x80); + EXPECT_EQ(frame[12], 0x18); + } +} + +} // namespace esphome::whirlpool::testing From e0436e26006a90d443cc0f1724d4e19a479096e3 Mon Sep 17 00:00:00 2001 From: emes30 <35312919+emes30@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:01:11 +0200 Subject: [PATCH 199/268] [rp2_pio] Use pioasm 4.1.0 so PIO programs run on RP2350 (#19260) Co-authored-by: J. Nick Koston --- esphome/components/rp2/__init__.py | 2 ++ esphome/components/rp2_pio/__init__.py | 8 ++++---- .../components/rp2040_pio_led_strip/test.rp2350-ard.yaml | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 76dd240f7a..d46858d397 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -195,6 +195,8 @@ def _parse_platform_version(value: Any) -> str: # * The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) +# * rp2_pio pins its own pioasm build (PIOASM_REPO_VERSION in rp2_pio/__init__.py); check +# https://github.com/earlephilhower/pico-quick-toolchain/releases for a matching release # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases diff --git a/esphome/components/rp2_pio/__init__.py b/esphome/components/rp2_pio/__init__.py index 1b2236ae8e..bfdff910da 100644 --- a/esphome/components/rp2_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -8,9 +8,9 @@ DEPENDENCIES = ["rp2"] DOMAIN = "rp2_pio" -PIOASM_REPO_VERSION = "1.5.0-b" +PIOASM_REPO_VERSION = "4.1.0" PIOASM_REPO_BASE = f"https://github.com/earlephilhower/pico-quick-toolchain/releases/download/{PIOASM_REPO_VERSION}" -PIOASM_VERSION = "pioasm-2e6142b.230216" +PIOASM_VERSION = "pioasm-9fdfe11.250530" PIOASM_DOWNLOADS = { "linux": { "aarch64": f"aarch64-linux-gnu.{PIOASM_VERSION}.tar.gz", @@ -21,8 +21,8 @@ PIOASM_DOWNLOADS = { "amd64": f"x86_64-w64-mingw32.{PIOASM_VERSION}.zip", }, "darwin": { - "x86_64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", - "arm64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", + "x86_64": f"x86_64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", + "arm64": f"aarch64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", }, } diff --git a/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml new file mode 100644 index 0000000000..25135f1d39 --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml @@ -0,0 +1,2 @@ +packages: + rp2040_pio_led_strip: !include common.yaml From 216e1bee5c9f1f3dfb001227230cdd6238003459 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 14:15:44 -0400 Subject: [PATCH 200/268] [socket] Read the LwIP interface list under the core lock (#20583) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/socket/socket.cpp | 37 +++++++++++++------ .../components/socket/test_multicast_host.cpp | 32 ++++++++++------ 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 36cd6d6ab7..dd9387785f 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -8,6 +8,7 @@ #include #include "esphome/core/log.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" #ifdef USE_HOST #include "esphome/core/wake.h" #endif @@ -198,24 +199,36 @@ template static bool foreach_eligible_ipv6_if(F &&callback) { } 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; + // The netif list needs the core lock, but the callback's setsockopt() takes that lock itself, so the + // indices are collected first. One that goes away before the callback runs is rejected by setsockopt(). + // Devices have only a few interfaces, so the cap drops nothing in practice. + static constexpr size_t MAX_IFS = 8; + StaticVector indices; + { + LwIPLock lock; + struct netif *netif; + NETIF_FOREACH(netif) { + if (indices.size() == MAX_IFS) { + break; + } + if (netif->name[0] == 'l' && netif->name[1] == 'o') { + continue; + } + if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) { + continue; + } + indices.push_back(netif_get_index(netif)); } - if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) { - continue; - } - found_any = true; - if (callback(netif_get_index(netif))) { + } + for (uint8_t index : indices) { + if (callback(index)) { break; } } - if (!found_any) { + if (indices.empty()) { errno = EADDRNOTAVAIL; } - return found_any; + return !indices.empty(); #endif } #endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/tests/components/socket/test_multicast_host.cpp b/tests/components/socket/test_multicast_host.cpp index 31fe286a86..d730e3a4a6 100644 --- a/tests/components/socket/test_multicast_host.cpp +++ b/tests/components/socket/test_multicast_host.cpp @@ -46,10 +46,14 @@ TEST(JoinMulticastGroup, IPv4JoinSetsIfIndexToZero) { } 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); + // Whether the host accepts the join or not, a null if_index_out must give the same answer + auto sock_with = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + auto sock_without = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock_with, nullptr); + ASSERT_NE(sock_without, nullptr); + uint32_t if_index = 0; + const bool with_out = join_multicast_group(sock_with.get(), "239.0.60.53", &if_index); + EXPECT_EQ(join_multicast_group(sock_without.get(), "239.0.60.53", nullptr), with_out); } // ========================================================================= @@ -62,11 +66,13 @@ 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) { + errno = 0; + if (join_multicast_group(sock.get(), "ff12::cafe", &if_index)) { EXPECT_GT(if_index, 0u); - } else { - EXPECT_EQ(errno, EADDRNOTAVAIL); + } else if (errno != EADDRNOTAVAIL) { + // EINVAL can only come from our own parsing, since the address literal is valid + ASSERT_NE(errno, EINVAL); + GTEST_SKIP() << "Host refused the IPv6 multicast join, errno " << errno; } } @@ -74,7 +80,9 @@ 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); + if (!set_ipv6_multicast_if(sock.get(), 0) && errno != EADDRNOTAVAIL) { + GTEST_SKIP() << "Host refused IPV6_MULTICAST_IF, errno " << errno; + } } TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) { @@ -83,10 +91,10 @@ TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) { 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)); + if (!join_multicast_group(sock_join.get(), "ff12::cafe", &if_index) || if_index == 0) { + GTEST_SKIP() << "Host has no interface that accepts an IPv6 multicast join"; } + EXPECT_TRUE(set_ipv6_multicast_if(sock_send.get(), if_index)); } #endif // USE_NETWORK_IPV6 From 66ba258baa5f82743eec28f8ff4ad6c59f1da823 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 14:53:22 -0400 Subject: [PATCH 201/268] [udp] Wake the main loop when a datagram arrives (#20585) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/udp/udp_component.cpp | 4 +- .../fixtures/socket_wake_gate_udp.yaml | 27 ++++++++ .../integration/test_socket_wake_gate_udp.py | 64 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/socket_wake_gate_udp.yaml create mode 100644 tests/integration/test_socket_wake_gate_udp.py diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 8e82403834..e6046ebc36 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -45,7 +45,7 @@ void UDPComponent::setup() { // create listening socket if we either want to subscribe to providers, or need to listen // for ping key broadcasts. if (this->should_listen_) { - this->listen_socket_ = socket::socket_udp(AF_INET, IPPROTO_IP); + this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET, IPPROTO_IP); if (this->listen_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -115,6 +115,8 @@ void UDPComponent::loop() { if (this->should_listen_) { std::array buf; for (;;) { + if (!this->listen_socket_->ready()) + break; auto len = this->listen_socket_->read(buf.data(), buf.size()); if (len <= 0) break; diff --git a/tests/integration/fixtures/socket_wake_gate_udp.yaml b/tests/integration/fixtures/socket_wake_gate_udp.yaml new file mode 100644 index 0000000000..27d6df9094 --- /dev/null +++ b/tests/integration/fixtures/socket_wake_gate_udp.yaml @@ -0,0 +1,27 @@ +esphome: + name: socket-wake-gate-udp + on_boot: + priority: -100 + then: + - lambda: |- + // Without a loop-monitored socket a datagram waits for loop_interval_ to expire. + App.set_loop_interval(2000); + - delay: 500ms + - lambda: |- + ESP_LOGI("test", "BOOT_DONE"); + +host: + +api: + +logger: + level: INFO + +udp: + - id: test_udp + port: UDP_LISTEN_PORT_PLACEHOLDER + addresses: ["127.0.0.1"] + on_receive: + - logger.log: + format: "PONG" + level: INFO diff --git a/tests/integration/test_socket_wake_gate_udp.py b/tests/integration/test_socket_wake_gate_udp.py new file mode 100644 index 0000000000..4edd8f14c2 --- /dev/null +++ b/tests/integration/test_socket_wake_gate_udp.py @@ -0,0 +1,64 @@ +"""Test that an incoming UDP datagram wakes the main loop at once.""" + +from __future__ import annotations + +import asyncio +import socket +import time + +import pytest + +from .types import RunCompiledFunction + + +@pytest.mark.asyncio +async def test_socket_wake_gate_udp( + yaml_config: str, + run_compiled: RunCompiledFunction, +) -> None: + """A datagram must be handled well before loop_interval_ (2000ms) expires.""" + # Hold the port with SO_REUSEADDR so nothing else claims it while the firmware builds. The + # device's listen socket sets SO_REUSEADDR too and binds the wildcard address alongside it. + hold_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + hold_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + hold_sock.bind(("127.0.0.1", 0)) + listen_port = hold_sock.getsockname()[1] + config = yaml_config.replace("UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port)) + + loop = asyncio.get_running_loop() + boot_done: asyncio.Future[None] = loop.create_future() + pong: asyncio.Future[None] = loop.create_future() + + def on_log_line(line: str) -> None: + if "BOOT_DONE" in line and not boot_done.done(): + boot_done.set_result(None) + if "PONG" in line and not pong.done(): + pong.set_result(None) + + try: + async with run_compiled(config, line_callback=on_log_line): + try: + await asyncio.wait_for(boot_done, timeout=15.0) + except TimeoutError: + pytest.fail("BOOT_DONE never appeared") + + # Datagrams go to the more specific bind, so release the hold before sending + hold_sock.close() + + # No API client is connected, so this datagram is the only thing that can wake select() + t_send = time.monotonic() + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as send_sock: + send_sock.sendto(b"PING", ("127.0.0.1", listen_port)) + + try: + await asyncio.wait_for(pong, timeout=5.0) + except TimeoutError: + pytest.fail("PONG never appeared") + + elapsed_ms = (time.monotonic() - t_send) * 1000 + # Without the wake the receive waits for loop_interval_; 500ms leaves CI headroom + assert elapsed_ms < 500, ( + f"UDP receive took {elapsed_ms:.0f}ms with loop_interval_=2000ms" + ) + finally: + hold_sock.close() From 5a3bee638f0475b3619dc42b884a516f0660d01a Mon Sep 17 00:00:00 2001 From: Jozsef Kiraly <371847+fonix232@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:33:33 +0100 Subject: [PATCH 202/268] [siemens_ira211] Add the Siemens IRA211 protocol and climate platform (#15154) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/remote_base/__init__.py | 93 ++++++ .../remote_base/ira211_protocol.cpp | 292 ++++++++++++++++++ .../components/remote_base/ira211_protocol.h | 65 ++++ esphome/components/siemens_ira211/__init__.py | 2 + esphome/components/siemens_ira211/climate.py | 17 + .../siemens_ira211/siemens_ira211.cpp | 167 ++++++++++ .../siemens_ira211/siemens_ira211.h | 29 ++ esphome/core/defines.h | 1 + .../config/ira211_action.yaml | 25 ++ .../remote_transmitter/test_ira211_action.py | 20 ++ tests/components/remote_base/__init__.py | 1 + tests/components/remote_base/ira211_test.cpp | 254 +++++++++++++++ .../remote_receiver/common-actions.yaml | 5 + .../remote_receiver/esp32-common.yaml | 7 + .../remote_transmitter/common-buttons.yaml | 8 + tests/components/siemens_ira211/common.yaml | 5 + .../siemens_ira211/test.esp32-idf.yaml | 4 + .../siemens_ira211/test.esp8266-ard.yaml | 4 + 19 files changed, 1000 insertions(+) create mode 100644 esphome/components/remote_base/ira211_protocol.cpp create mode 100644 esphome/components/remote_base/ira211_protocol.h create mode 100644 esphome/components/siemens_ira211/__init__.py create mode 100644 esphome/components/siemens_ira211/climate.py create mode 100644 esphome/components/siemens_ira211/siemens_ira211.cpp create mode 100644 esphome/components/siemens_ira211/siemens_ira211.h create mode 100644 tests/component_tests/remote_transmitter/config/ira211_action.yaml create mode 100644 tests/component_tests/remote_transmitter/test_ira211_action.py create mode 100644 tests/components/remote_base/ira211_test.cpp create mode 100644 tests/components/siemens_ira211/common.yaml create mode 100644 tests/components/siemens_ira211/test.esp32-idf.yaml create mode 100644 tests/components/siemens_ira211/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 7e263a4514..db9dcccd7f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -502,6 +502,7 @@ esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me esphome/components/sht4x/* @sjtrny esphome/components/shutdown/* @esphome/core @jsuanet +esphome/components/siemens_ira211/* @fonix232 esphome/components/sigma_delta_output/* @Cat-Ion esphome/components/sim800l/* @glmnet esphome/components/sm10bit_base/* @Cossid diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index e347ee96ff..9cf9493cd0 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -21,6 +21,7 @@ from esphome.const import ( CONF_DELTA, CONF_DEVICE, CONF_FAMILY, + CONF_FAN_MODE, CONF_FORCE_UPDATE, CONF_GROUP, CONF_HUMIDITY, @@ -30,6 +31,7 @@ from esphome.const import ( CONF_KEY, CONF_LEVEL, CONF_MAGNITUDE, + CONF_MODE, CONF_NBITS, CONF_ONE, CONF_PROTOCOL, @@ -2771,3 +2773,94 @@ register_protocol_action( (CONF_COMMAND, "command", cg.uint8), validate=_toto_default_repeat, ) + + +# IRA211 +( + IRA211Data, + IRA211BinarySensor, + IRA211Trigger, + _, + IRA211Dumper, +) = declare_protocol("IRA211") + +IRA211CommandEnum = ns.enum("IRA211Command", is_class=True) +IRA211_COMMANDS = { + "TEMP_UP": IRA211CommandEnum.IRA211_COMMAND_TEMP_UP, + "TEMP_DOWN": IRA211CommandEnum.IRA211_COMMAND_TEMP_DOWN, + "MODE": IRA211CommandEnum.IRA211_COMMAND_MODE, + "FAN": IRA211CommandEnum.IRA211_COMMAND_FAN, + "POWER": IRA211CommandEnum.IRA211_COMMAND_POWER, + "SYNC": IRA211CommandEnum.IRA211_COMMAND_SYNC, +} + +IRA211ModeEnum = ns.enum("IRA211Mode", is_class=True) +IRA211_MODES = { + "PROTECTION": IRA211ModeEnum.IRA211_MODE_PROTECTION, + "TIMER": IRA211ModeEnum.IRA211_MODE_TIMER, + "COMFORT": IRA211ModeEnum.IRA211_MODE_COMFORT, +} + +IRA211FanEnum = ns.enum("IRA211Fan", is_class=True) +IRA211_FAN_SPEEDS = { + "AUTO": IRA211FanEnum.IRA211_FAN_AUTO, + "LOW": IRA211FanEnum.IRA211_FAN_LOW, + "MEDIUM": IRA211FanEnum.IRA211_FAN_MEDIUM, + "HIGH": IRA211FanEnum.IRA211_FAN_HIGH, +} + + +def _half_degree_step(value: float) -> float: + if value * 2 != int(value * 2): + raise cv.Invalid("Temperature must be a multiple of 0.5") + return value + + +IRA211_SCHEMA = cv.Schema( + { + cv.Required(CONF_COMMAND): cv.enum(IRA211_COMMANDS, upper=True), + cv.Optional(CONF_TEMPERATURE, default=20): cv.All( + cv.float_range(min=5, max=35), _half_degree_step + ), + cv.Optional(CONF_MODE, default="COMFORT"): cv.enum(IRA211_MODES, upper=True), + cv.Optional(CONF_FAN_MODE, default="AUTO"): cv.enum( + IRA211_FAN_SPEEDS, upper=True + ), + } +) + + +@register_binary_sensor("ira211", IRA211BinarySensor, IRA211_SCHEMA) +def ira211_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + IRA211Data, + ("command", config[CONF_COMMAND]), + ("half_degrees", int(config[CONF_TEMPERATURE] * 2)), + ("mode", config[CONF_MODE]), + ("fan", config[CONF_FAN_MODE]), + ) + ) + ) + + +@register_trigger("ira211", IRA211Trigger, IRA211Data) +def ira211_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("ira211", IRA211Dumper) +def ira211_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "ira211", + "IRA211", + IRA211_SCHEMA, + (CONF_COMMAND, "command", IRA211CommandEnum), + automation.ApplyField(CONF_TEMPERATURE, "data.set_temperature({})", cg.float_), + (CONF_MODE, "mode", IRA211ModeEnum), + (CONF_FAN_MODE, "fan", IRA211FanEnum), +) diff --git a/esphome/components/remote_base/ira211_protocol.cpp b/esphome/components/remote_base/ira211_protocol.cpp new file mode 100644 index 0000000000..b9cd6ec886 --- /dev/null +++ b/esphome/components/remote_base/ira211_protocol.cpp @@ -0,0 +1,292 @@ +#include "ira211_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.ira211"); + +static constexpr uint32_t CARRIER_FREQUENCY = 38000; +static constexpr uint32_t T_US = 800; +static constexpr uint32_t HEADER_US = 19 * T_US / 2; // 7600 us, also the gap that separates frames +// Midway between the longest data run (9 bits, 7200 us) and the gap, so a gap captured a little short still +// ends the frame +static constexpr uint32_t GAP_MIN_US = HEADER_US - T_US / 4; +static constexpr uint8_t MIN_HALF_DEGREES = static_cast(IRA211_MIN_TEMPERATURE * 2); +static constexpr uint8_t MAX_HALF_DEGREES = static_cast(IRA211_MAX_TEMPERATURE * 2); + +static constexpr uint8_t DEVICE_ID = 0x66; +static constexpr uint8_t BITS_PER_PACKET = 10; +static constexpr uint8_t MIN_PACKETS = 4; +static constexpr uint8_t MAX_PACKETS = 7; +static constexpr uint8_t MAX_BITS = MAX_PACKETS * BITS_PER_PACKET; + +// Fields each command carries, in wire order after the command byte: temperature (whole degrees +// then tenths), mode, fan. +static constexpr uint8_t FIELD_TEMPERATURE = 1 << 0; +static constexpr uint8_t FIELD_MODE = 1 << 1; +static constexpr uint8_t FIELD_FAN = 1 << 2; + +struct CommandLayout { + IRA211Command command; + uint8_t fields; +}; + +static const CommandLayout LAYOUTS[] PROGMEM = { + {IRA211Command::IRA211_COMMAND_TEMP_UP, FIELD_TEMPERATURE}, + {IRA211Command::IRA211_COMMAND_TEMP_DOWN, FIELD_TEMPERATURE}, + {IRA211Command::IRA211_COMMAND_MODE, FIELD_MODE}, + {IRA211Command::IRA211_COMMAND_FAN, FIELD_FAN}, + {IRA211Command::IRA211_COMMAND_POWER, FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN}, + {IRA211Command::IRA211_COMMAND_SYNC, FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN}, +}; + +static optional find_layout(IRA211Command command) { + for (const auto &slot : LAYOUTS) { + CommandLayout layout; + progmem_memcpy(&layout, &slot, sizeof(layout)); + if (layout.command == command) + return layout; + } + return {}; +} + +// Device ID, command, fields, checksum +static uint8_t packet_count(const CommandLayout &layout) { + return 3 + ((layout.fields & FIELD_TEMPERATURE) ? 2 : 0) + ((layout.fields & FIELD_MODE) ? 1 : 0) + + ((layout.fields & FIELD_FAN) ? 1 : 0); +} + +// Value to wire byte and back; the transform is its own inverse +static uint8_t wire_xform(uint8_t value) { return reverse_bits(static_cast(~value)); } + +static bool is_known_mode(uint8_t value) { + switch (static_cast(value)) { + case IRA211Mode::IRA211_MODE_PROTECTION: + case IRA211Mode::IRA211_MODE_TIMER: + case IRA211Mode::IRA211_MODE_COMFORT: + return true; + default: + return false; + } +} + +static bool is_known_fan(uint8_t value) { + switch (static_cast(value)) { + case IRA211Fan::IRA211_FAN_AUTO: + case IRA211Fan::IRA211_FAN_LOW: + case IRA211Fan::IRA211_FAN_MEDIUM: + case IRA211Fan::IRA211_FAN_HIGH: + return true; + default: + return false; + } +} + +static uint8_t checksum(const uint8_t *bytes, uint8_t count) { + uint8_t sum = count; + for (uint8_t i = 0; i < count; i++) + sum += reverse_bits(bytes[i]); + return reverse_bits(sum); +} + +bool IRA211Data::operator==(const IRA211Data &rhs) const { + if (this->command != rhs.command) + return false; + const optional layout = find_layout(this->command); + if (!layout.has_value()) + return true; + if ((layout->fields & FIELD_TEMPERATURE) && this->half_degrees != rhs.half_degrees) + return false; + if ((layout->fields & FIELD_MODE) && this->mode != rhs.mode) + return false; + return !(layout->fields & FIELD_FAN) || this->fan == rhs.fan; +} + +void IRA211Protocol::encode(RemoteTransmitData *dst, const IRA211Data &data) { + const optional layout = find_layout(data.command); + if (!layout.has_value()) + return; + + uint8_t bytes[MAX_PACKETS]; + uint8_t count = 0; + bytes[count++] = DEVICE_ID; + bytes[count++] = wire_xform(static_cast(data.command)); + if (layout->fields & FIELD_TEMPERATURE) { + bytes[count++] = wire_xform(data.half_degrees / 2); + bytes[count++] = wire_xform((data.half_degrees & 1) ? 5 : 0); + } + if (layout->fields & FIELD_MODE) + bytes[count++] = wire_xform(static_cast(data.mode)); + if (layout->fields & FIELD_FAN) + bytes[count++] = wire_xform(static_cast(data.fan)); + bytes[count] = checksum(bytes, count); + count++; + + dst->set_carrier_frequency(CARRIER_FREQUENCY); + // Preamble, at most one item per bit, trailing mark and gap + dst->reserve(4 + count * BITS_PER_PACKET + 2); + dst->item(HEADER_US, T_US); + dst->item(T_US, HEADER_US); + + // Each packet is a 1 bit, the byte MSB first, then a 0 bit; runs of equal bits become one mark or space + bool level = true; + uint32_t run = 0; + for (uint8_t p = 0; p < count; p++) { + const uint16_t packet = (1 << 9) | (bytes[p] << 1); + for (int8_t i = BITS_PER_PACKET - 1; i >= 0; i--) { + const bool bit = (packet >> i) & 1; + if (bit != level && run != 0) { + if (level) { + dst->mark(run * T_US); + } else { + dst->space(run * T_US); + } + run = 0; + } + level = bit; + run++; + } + } + // Every packet ends with a 0 bit, so the frame ends with a space, closed by a trailing mark + dst->space(run * T_US); + dst->mark(T_US); + dst->space(HEADER_US); +} + +optional IRA211Protocol::decode(RemoteReceiveData src) { + if (!src.expect_item(HEADER_US, T_US) || !src.expect_item(T_US, HEADER_US)) + return {}; + + // Read bits until the end-of-frame gap, checking packet framing and collecting bytes as they arrive + uint8_t bytes[MAX_PACKETS]{}; + uint8_t bits = 0; + while (src.is_valid()) { + const int32_t raw = src.peek(); + const bool is_mark = raw > 0; + const uint32_t duration = is_mark ? raw : -raw; + // A mark followed by the gap or the end of the data is the trailing mark, not a data bit + if (is_mark && (!src.is_valid(1) || src.peek(1) <= -static_cast(GAP_MIN_US))) + break; + if (!is_mark && duration >= GAP_MIN_US) + break; + const uint32_t run = (duration + T_US / 2) / T_US; + if (run == 0 || bits + run > MAX_BITS) + return {}; + for (uint32_t i = 0; i < run; i++, bits++) { + const uint8_t pos = bits % BITS_PER_PACKET; + if ((pos == 0 && !is_mark) || (pos == BITS_PER_PACKET - 1 && is_mark)) + return {}; + if (pos != 0 && pos != BITS_PER_PACKET - 1) { + uint8_t &byte = bytes[bits / BITS_PER_PACKET]; + byte = (byte << 1) | (is_mark ? 1 : 0); + } + } + src.advance(); + } + + const uint8_t count = bits / BITS_PER_PACKET; + if (bits % BITS_PER_PACKET != 0 || count < MIN_PACKETS || bytes[0] != DEVICE_ID) + return {}; + IRA211Data out{}; + out.command = static_cast(wire_xform(bytes[1])); + const optional layout = find_layout(out.command); + if (!layout.has_value() || packet_count(*layout) != count || checksum(bytes, count - 1) != bytes[count - 1]) { + ESP_LOGV(TAG, "Invalid frame (%u bits)", bits); + return {}; + } + + // A checksum-valid frame can still carry values outside what the thermostat accepts + uint8_t index = 2; + if (layout->fields & FIELD_TEMPERATURE) { + const uint8_t whole = wire_xform(bytes[index++]); + const uint8_t tenths = wire_xform(bytes[index++]); + const uint16_t half_degrees = whole * 2 + (tenths >= 5 ? 1 : 0); + if (half_degrees < MIN_HALF_DEGREES || half_degrees > MAX_HALF_DEGREES) + return {}; + out.half_degrees = static_cast(half_degrees); + } + if (layout->fields & FIELD_MODE) { + const uint8_t mode = wire_xform(bytes[index++]); + if (!is_known_mode(mode)) + return {}; + out.mode = static_cast(mode); + } + if (layout->fields & FIELD_FAN) { + const uint8_t fan = wire_xform(bytes[index++]); + if (!is_known_fan(fan)) + return {}; + out.fan = static_cast(fan); + } + return out; +} + +static const LogString *command_to_string(IRA211Command command) { + switch (command) { + case IRA211Command::IRA211_COMMAND_TEMP_UP: + return LOG_STR("TEMP_UP"); + case IRA211Command::IRA211_COMMAND_TEMP_DOWN: + return LOG_STR("TEMP_DOWN"); + case IRA211Command::IRA211_COMMAND_MODE: + return LOG_STR("MODE"); + case IRA211Command::IRA211_COMMAND_FAN: + return LOG_STR("FAN"); + case IRA211Command::IRA211_COMMAND_POWER: + return LOG_STR("POWER"); + case IRA211Command::IRA211_COMMAND_SYNC: + return LOG_STR("SYNC"); + default: + return LOG_STR("UNKNOWN"); + } +} + +static const LogString *mode_to_string(IRA211Mode mode) { + switch (mode) { + case IRA211Mode::IRA211_MODE_PROTECTION: + return LOG_STR("Protection"); + case IRA211Mode::IRA211_MODE_TIMER: + return LOG_STR("Timer"); + case IRA211Mode::IRA211_MODE_COMFORT: + return LOG_STR("Comfort"); + default: + return LOG_STR("Unknown"); + } +} + +static const LogString *fan_to_string(IRA211Fan fan) { + switch (fan) { + case IRA211Fan::IRA211_FAN_AUTO: + return LOG_STR("Auto"); + case IRA211Fan::IRA211_FAN_LOW: + return LOG_STR("1/3"); + case IRA211Fan::IRA211_FAN_MEDIUM: + return LOG_STR("2/3"); + case IRA211Fan::IRA211_FAN_HIGH: + return LOG_STR("3/3"); + default: + return LOG_STR("Unknown"); + } +} + +void IRA211Protocol::dump(const IRA211Data &data) { + const optional layout = find_layout(data.command); + const uint8_t fields = layout.has_value() ? layout->fields : 0; + const unsigned whole = data.half_degrees / 2; + const unsigned tenths = (data.half_degrees & 1) ? 5 : 0; + const auto *command = LOG_STR_ARG(command_to_string(data.command)); + if (fields == (FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN)) { + ESP_LOGI(TAG, "Received IRA211: command=%s, temperature=%u.%uC, mode=%s, fan=%s", command, whole, tenths, + LOG_STR_ARG(mode_to_string(data.mode)), LOG_STR_ARG(fan_to_string(data.fan))); + } else if (fields == FIELD_TEMPERATURE) { + ESP_LOGI(TAG, "Received IRA211: command=%s, temperature=%u.%uC", command, whole, tenths); + } else if (fields == FIELD_MODE) { + ESP_LOGI(TAG, "Received IRA211: command=%s, mode=%s", command, LOG_STR_ARG(mode_to_string(data.mode))); + } else if (fields == FIELD_FAN) { + ESP_LOGI(TAG, "Received IRA211: command=%s, fan=%s", command, LOG_STR_ARG(fan_to_string(data.fan))); + } else { + ESP_LOGI(TAG, "Received IRA211: command=%s", command); + } +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/ira211_protocol.h b/esphome/components/remote_base/ira211_protocol.h new file mode 100644 index 0000000000..205d09215a --- /dev/null +++ b/esphome/components/remote_base/ira211_protocol.h @@ -0,0 +1,65 @@ +#pragma once + +#include "remote_base.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::remote_base { + +/* Siemens IRA211 thermostat remote: 38 kHz carrier, NRZ runs of 800 us bits after a fixed preamble. + A frame is 4, 5 or 7 ten-bit packets (start bit 1, 8 data bits, end bit 0): device ID, command, + the command's fields, then a checksum. Field bytes go over the wire as reverse_bits(~value). */ + +enum class IRA211Command : uint8_t { + IRA211_COMMAND_TEMP_UP = 1, + IRA211_COMMAND_TEMP_DOWN = 2, + IRA211_COMMAND_MODE = 4, + IRA211_COMMAND_FAN = 5, + IRA211_COMMAND_POWER = 6, + IRA211_COMMAND_SYNC = 7, +}; + +enum class IRA211Mode : uint8_t { + IRA211_MODE_PROTECTION = 0, + IRA211_MODE_TIMER = 85, + IRA211_MODE_COMFORT = 170, +}; + +enum class IRA211Fan : uint8_t { + IRA211_FAN_AUTO = 3, + IRA211_FAN_LOW = 12, + IRA211_FAN_MEDIUM = 48, + IRA211_FAN_HIGH = 192, +}; + +static constexpr float IRA211_MIN_TEMPERATURE = 5.0f; +static constexpr float IRA211_MAX_TEMPERATURE = 35.0f; + +struct IRA211Data { + IRA211Command command{IRA211Command::IRA211_COMMAND_SYNC}; + uint8_t half_degrees{40}; // target temperature in 0.5 C steps + IRA211Mode mode{IRA211Mode::IRA211_MODE_COMFORT}; + IRA211Fan fan{IRA211Fan::IRA211_FAN_AUTO}; + + float get_temperature() const { return this->half_degrees * 0.5f; } + /// Rounds to the nearest half degree and clamps to the 5 to 35 C range the thermostat accepts. + void set_temperature(float temperature) { + this->half_degrees = + static_cast(lroundf(clamp(temperature, IRA211_MIN_TEMPERATURE, IRA211_MAX_TEMPERATURE) * 2.0f)); + } + + /// Compares the command and only the fields that command carries. + bool operator==(const IRA211Data &rhs) const; +}; + +class IRA211Protocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const IRA211Data &data); + optional decode(RemoteReceiveData src); + void dump(const IRA211Data &data); +}; + +DECLARE_REMOTE_PROTOCOL(IRA211) + +} // namespace esphome::remote_base diff --git a/esphome/components/siemens_ira211/__init__.py b/esphome/components/siemens_ira211/__init__.py new file mode 100644 index 0000000000..1407aff81e --- /dev/null +++ b/esphome/components/siemens_ira211/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@fonix232"] +DOMAIN = "siemens_ira211" diff --git a/esphome/components/siemens_ira211/climate.py b/esphome/components/siemens_ira211/climate.py new file mode 100644 index 0000000000..16345e70f1 --- /dev/null +++ b/esphome/components/siemens_ira211/climate.py @@ -0,0 +1,17 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +siemens_ira211_ns = cg.esphome_ns.namespace("siemens_ira211") +SiemensIRA211Climate = siemens_ira211_ns.class_( + "SiemensIRA211Climate", climate_ir.ClimateIR +) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(SiemensIRA211Climate) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("ira211") # used from C++ + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/siemens_ira211/siemens_ira211.cpp b/esphome/components/siemens_ira211/siemens_ira211.cpp new file mode 100644 index 0000000000..e332134eac --- /dev/null +++ b/esphome/components/siemens_ira211/siemens_ira211.cpp @@ -0,0 +1,167 @@ +#include "siemens_ira211.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::siemens_ira211 { + +using namespace remote_base; + +struct PresetMapping { + climate::ClimatePreset preset; + IRA211Mode mode; +}; + +static const PresetMapping PRESETS[] PROGMEM = { + {climate::CLIMATE_PRESET_COMFORT, IRA211Mode::IRA211_MODE_COMFORT}, + {climate::CLIMATE_PRESET_ECO, IRA211Mode::IRA211_MODE_TIMER}, +}; + +struct FanMapping { + climate::ClimateFanMode fan_mode; + IRA211Fan fan; +}; + +static const FanMapping FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, IRA211Fan::IRA211_FAN_AUTO}, + {climate::CLIMATE_FAN_LOW, IRA211Fan::IRA211_FAN_LOW}, + {climate::CLIMATE_FAN_MEDIUM, IRA211Fan::IRA211_FAN_MEDIUM}, + {climate::CLIMATE_FAN_HIGH, IRA211Fan::IRA211_FAN_HIGH}, +}; + +// The tables live in flash, so entries are copied out before use +template static T load_entry(const T &slot) { + T entry; + progmem_memcpy(&entry, &slot, sizeof(T)); + return entry; +} + +// The first entry of each table is the fallback for values the other side does not know +static IRA211Mode to_ira211_mode(climate::ClimatePreset preset) { + for (const auto &slot : PRESETS) { + const auto entry = load_entry(slot); + if (entry.preset == preset) + return entry.mode; + } + return load_entry(PRESETS[0]).mode; +} + +static climate::ClimatePreset to_climate_preset(IRA211Mode mode) { + for (const auto &slot : PRESETS) { + const auto entry = load_entry(slot); + if (entry.mode == mode) + return entry.preset; + } + return load_entry(PRESETS[0]).preset; +} + +static IRA211Fan to_ira211_fan(climate::ClimateFanMode fan_mode) { + for (const auto &slot : FANS) { + const auto entry = load_entry(slot); + if (entry.fan_mode == fan_mode) + return entry.fan; + } + return load_entry(FANS[0]).fan; +} + +static climate::ClimateFanMode to_climate_fan_mode(IRA211Fan fan) { + for (const auto &slot : FANS) { + const auto entry = load_entry(slot); + if (entry.fan == fan) + return entry.fan_mode; + } + return load_entry(FANS[0]).fan_mode; +} + +void SiemensIRA211Climate::setup() { + // The thermostat decides between heating and cooling itself, so one mode stands for both + if (this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL)) { + this->modes_.erase(climate::CLIMATE_MODE_HEAT); + this->modes_.erase(climate::CLIMATE_MODE_COOL); + } + climate_ir::ClimateIR::setup(); + // Match the restored state, so the first command after a reboot does not send a needless POWER frame + this->is_on_ = this->mode != climate::CLIMATE_MODE_OFF; +} + +climate::ClimateMode SiemensIRA211Climate::active_mode_() const { + if (this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL)) + return climate::CLIMATE_MODE_HEAT_COOL; + if (this->modes_.count(climate::CLIMATE_MODE_HEAT)) + return climate::CLIMATE_MODE_HEAT; + return climate::CLIMATE_MODE_COOL; +} + +void SiemensIRA211Climate::transmit_frame_(IRA211Command command, IRA211Mode mode, IRA211Fan fan) { + IRA211Data data{}; + data.command = command; + data.set_temperature(this->target_temperature); + data.mode = mode; + data.fan = fan; + this->transmit_(data); +} + +void SiemensIRA211Climate::transmit_state() { + const IRA211Mode mode = to_ira211_mode(this->preset.value_or(climate::CLIMATE_PRESET_COMFORT)); + const IRA211Fan fan = to_ira211_fan(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + // Show the preset that is actually sent, so a fresh entity does not stay on "none" + this->preset = to_climate_preset(mode); + + if (this->mode == climate::CLIMATE_MODE_OFF) { + this->transmit_frame_(IRA211Command::IRA211_COMMAND_POWER, IRA211Mode::IRA211_MODE_PROTECTION, fan); + this->is_on_ = false; + return; + } + if (!this->is_on_) { + // Turning on takes a POWER frame before the state is sent + this->transmit_frame_(IRA211Command::IRA211_COMMAND_POWER, mode, fan); + this->is_on_ = true; + } + this->transmit_frame_(IRA211Command::IRA211_COMMAND_SYNC, mode, fan); +} + +void SiemensIRA211Climate::apply_mode_(IRA211Mode mode) { + // The thermostat reports "off" as the PROTECTION mode + if (mode == IRA211Mode::IRA211_MODE_PROTECTION) { + this->mode = climate::CLIMATE_MODE_OFF; + this->is_on_ = false; + return; + } + if (!this->is_on_) { + this->mode = this->active_mode_(); + this->is_on_ = true; + } + this->preset = to_climate_preset(mode); +} + +bool SiemensIRA211Climate::on_receive(RemoteReceiveData data) { + auto decoded = IRA211Protocol().decode(data); + if (!decoded.has_value()) + return false; + + const IRA211Data &frame = *decoded; + switch (frame.command) { + case IRA211Command::IRA211_COMMAND_SYNC: + case IRA211Command::IRA211_COMMAND_POWER: + this->apply_mode_(frame.mode); + this->target_temperature = frame.get_temperature(); + this->fan_mode = to_climate_fan_mode(frame.fan); + break; + case IRA211Command::IRA211_COMMAND_TEMP_UP: + case IRA211Command::IRA211_COMMAND_TEMP_DOWN: + this->target_temperature = frame.get_temperature(); + break; + case IRA211Command::IRA211_COMMAND_MODE: + this->apply_mode_(frame.mode); + break; + case IRA211Command::IRA211_COMMAND_FAN: + this->fan_mode = to_climate_fan_mode(frame.fan); + break; + default: + return false; + } + + this->publish_state(); + return true; +} + +} // namespace esphome::siemens_ira211 diff --git a/esphome/components/siemens_ira211/siemens_ira211.h b/esphome/components/siemens_ira211/siemens_ira211.h new file mode 100644 index 0000000000..dcba2ddcb9 --- /dev/null +++ b/esphome/components/siemens_ira211/siemens_ira211.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/components/remote_base/ira211_protocol.h" + +namespace esphome::siemens_ira211 { + +class SiemensIRA211Climate final : public climate_ir::ClimateIR { + public: + void setup() override; + SiemensIRA211Climate() + : climate_ir::ClimateIR(remote_base::IRA211_MIN_TEMPERATURE, remote_base::IRA211_MAX_TEMPERATURE, 0.5f, false, + false, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {}, {climate::CLIMATE_PRESET_COMFORT, climate::CLIMATE_PRESET_ECO}) {} + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + void transmit_frame_(remote_base::IRA211Command command, remote_base::IRA211Mode mode, remote_base::IRA211Fan fan); + void apply_mode_(remote_base::IRA211Mode mode); + climate::ClimateMode active_mode_() const; + + bool is_on_{false}; +}; + +} // namespace esphome::siemens_ira211 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 628435a4d8..3168d269cf 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -170,6 +170,7 @@ #define USE_REMOTE_PROTOCOL_GOBOX #define USE_REMOTE_PROTOCOL_HAIER #define USE_REMOTE_PROTOCOL_HOB2HOOD +#define USE_REMOTE_PROTOCOL_IRA211 #define USE_REMOTE_PROTOCOL_JVC #define USE_REMOTE_PROTOCOL_KEELOQ #define USE_REMOTE_PROTOCOL_LG diff --git a/tests/component_tests/remote_transmitter/config/ira211_action.yaml b/tests/component_tests/remote_transmitter/config/ira211_action.yaml new file mode 100644 index 0000000000..3cba9245ab --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/ira211_action.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: IRA211 constant + on_press: + - remote_transmitter.transmit_ira211: + command: sync + temperature: 22.5 + mode: timer + fan_mode: high + - platform: template + name: IRA211 lambda temperature + on_press: + - remote_transmitter.transmit_ira211: + command: sync + temperature: !lambda return 21.0f; diff --git a/tests/component_tests/remote_transmitter/test_ira211_action.py b/tests/component_tests/remote_transmitter/test_ira211_action.py new file mode 100644 index 0000000000..2d202998f1 --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_ira211_action.py @@ -0,0 +1,20 @@ +"""transmit_ira211 sets the temperature through IRA211Data::set_temperature().""" + +from collections.abc import Callable +from pathlib import Path + +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_ira211_temperature_is_a_statement_on_the_data( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("ira211_action.yaml")) + constant, lambda_ = protocol_call_bodies(main_cpp) + assert "apply_call.data.set_temperature(22.5f);" in constant + assert ( + "apply_call.data.mode = remote_base::IRA211Mode::IRA211_MODE_TIMER;" in constant + ) + assert "apply_call.data.fan = remote_base::IRA211Fan::IRA211_FAN_HIGH;" in constant + assert "apply_call.data.set_temperature(static_cast(21.0f));" in lambda_ diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 2fe959bb16..f104557265 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "aeha", "keeloq", "nexus", + "ira211", "nec", "magiquest", "rc5x", diff --git a/tests/components/remote_base/ira211_test.cpp b/tests/components/remote_base/ira211_test.cpp new file mode 100644 index 0000000000..f23d68640d --- /dev/null +++ b/tests/components/remote_base/ira211_test.cpp @@ -0,0 +1,254 @@ +#include +#include "esphome/components/remote_base/ira211_protocol.h" + +#include +#include +#include +#include +#include + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint32_t T_US = 800; +constexpr uint32_t HEADER_US = 7600; +constexpr size_t PREAMBLE_ITEMS = 4; // 7600 mark, 800 space, 800 mark, 7600 space +constexpr size_t BITS_PER_PACKET = 10; + +constexpr std::array ALL_COMMANDS = { + IRA211Command::IRA211_COMMAND_TEMP_UP, IRA211Command::IRA211_COMMAND_TEMP_DOWN, IRA211Command::IRA211_COMMAND_MODE, + IRA211Command::IRA211_COMMAND_FAN, IRA211Command::IRA211_COMMAND_POWER, IRA211Command::IRA211_COMMAND_SYNC, +}; +constexpr std::array ALL_MODES = { + IRA211Mode::IRA211_MODE_PROTECTION, + IRA211Mode::IRA211_MODE_TIMER, + IRA211Mode::IRA211_MODE_COMFORT, +}; +constexpr std::array ALL_FANS = { + IRA211Fan::IRA211_FAN_AUTO, + IRA211Fan::IRA211_FAN_LOW, + IRA211Fan::IRA211_FAN_MEDIUM, + IRA211Fan::IRA211_FAN_HIGH, +}; +constexpr std::array TEMPERATURES = {5.0f, 20.5f, 35.0f}; + +bool carries_temperature(IRA211Command command) { + return command != IRA211Command::IRA211_COMMAND_MODE && command != IRA211Command::IRA211_COMMAND_FAN; +} + +IRA211Data make_data(IRA211Command command, float temperature, IRA211Mode mode, IRA211Fan fan) { + IRA211Data data{}; + data.command = command; + data.set_temperature(temperature); + data.mode = mode; + data.fan = fan; + return data; +} + +RawTimings encode(const IRA211Data &data) { + RemoteTransmitData transmit; + IRA211Protocol().encode(&transmit, data); + return transmit.get_data(); +} + +optional decode(const RawTimings &timings) { + return IRA211Protocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +std::string describe(const IRA211Data &data) { + char buf[64]; + snprintf(buf, sizeof(buf), "command %d temperature %.1f mode %d fan %d", int(data.command), data.get_temperature(), + int(data.mode), int(data.fan)); + return buf; +} + +void expect_round_trip(const RawTimings &timings, const IRA211Data &expected) { + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()) << describe(expected); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + if (decoded.has_value()) { + EXPECT_TRUE(*decoded == expected) << describe(expected) << " decoded as " << describe(*decoded); + if (carries_temperature(expected.command)) + EXPECT_EQ(decoded->get_temperature(), expected.get_temperature()) << describe(expected); + } +} + +// Index of the first run of packet `packet`, and of the run after its last one. Packets start with a 1 bit +// after a 0 end bit and end with a 0 bit before a 1 start bit, so no run crosses a packet boundary. +std::pair packet_run_range(const RawTimings &timings, size_t packet) { + size_t begin = 0, end = 0, bits = 0; + for (size_t i = PREAMBLE_ITEMS; i < timings.size(); i++) { + if (bits == packet * BITS_PER_PACKET) + begin = i; + if (bits == (packet + 1) * BITS_PER_PACKET) { + end = i; + break; + } + bits += (std::abs(timings[i]) + T_US / 2) / T_US; + } + return {begin, end}; +} + +} // namespace + +TEST(IRA211ProtocolTest, RoundTripsEveryCommandTemperatureModeAndFan) { + for (auto command : ALL_COMMANDS) { + for (auto temperature : TEMPERATURES) { + for (auto mode : ALL_MODES) { + for (auto fan : ALL_FANS) { + const IRA211Data data = make_data(command, temperature, mode, fan); + expect_round_trip(encode(data), data); + } + } + } + } +} + +TEST(IRA211ProtocolTest, EveryFrameEndsWithTheTrailingMarkAndGap) { + for (auto command : ALL_COMMANDS) { + const RawTimings timings = + encode(make_data(command, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO)); + ASSERT_GE(timings.size(), PREAMBLE_ITEMS + 2) << "command " << int(command); + EXPECT_EQ(timings[timings.size() - 2], int32_t(T_US)) << "command " << int(command); + EXPECT_EQ(timings.back(), -int32_t(HEADER_US)) << "command " << int(command); + } +} + +TEST(IRA211ProtocolTest, SetTemperatureClampsAndRoundsToHalfDegrees) { + const struct { + float input; + float expected; + } cases[] = { + {4.0f, 5.0f}, {36.0f, 35.0f}, {22.26f, 22.5f}, {22.24f, 22.0f}, + {-1.0f, 5.0f}, {140.0f, 35.0f}, {5.0f, 5.0f}, {35.0f, 35.0f}, + }; + for (const auto &c : cases) { + IRA211Data data{}; + data.set_temperature(c.input); + EXPECT_EQ(data.get_temperature(), c.expected) << "input " << c.input; + } +} + +// A receiver that stops capturing at the frame gap never sees the trailing mark nor the gap after it. +TEST(IRA211ProtocolTest, DecodesWithoutTheTrailingMarkAndGap) { + for (auto command : ALL_COMMANDS) { + for (auto fan : ALL_FANS) { + const IRA211Data data = make_data(command, 35.0f, IRA211Mode::IRA211_MODE_TIMER, fan); + RawTimings timings = encode(data); + timings.pop_back(); // 7600 us gap + timings.pop_back(); // 800 us trailing mark + expect_round_trip(timings, data); + } + } +} + +// Move one bit between two neighbouring runs inside the checksum packet: the bit count and the framing bits +// stay valid, so only the checksum byte changes. +TEST(IRA211ProtocolTest, RejectsACorruptedChecksum) { + for (auto command : ALL_COMMANDS) { + const IRA211Data data = make_data(command, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW); + RawTimings timings = encode(data); + ASSERT_TRUE(decode(timings).has_value()) << describe(data); + size_t bits = 0; + for (size_t i = PREAMBLE_ITEMS; i + 2 < timings.size(); i++) + bits += (std::abs(timings[i]) + T_US / 2) / T_US; + ASSERT_EQ(bits % BITS_PER_PACKET, 0u) << describe(data); + const auto [begin, end] = packet_run_range(timings, bits / BITS_PER_PACKET - 1); + ASSERT_GT(end, begin) << describe(data); + // Shrink the first run that is at least two bits long and grow its neighbour; a run that keeps one bit + // keeps the start or end bit it may hold. + bool corrupted = false; + for (size_t i = begin; i < end && !corrupted; i++) { + if (std::abs(timings[i]) < int32_t(2 * T_US)) + continue; + const size_t neighbour = i + 1 < end ? i + 1 : i - 1; + timings[i] -= timings[i] > 0 ? int32_t(T_US) : -int32_t(T_US); + timings[neighbour] += timings[neighbour] > 0 ? int32_t(T_US) : -int32_t(T_US); + corrupted = true; + } + ASSERT_TRUE(corrupted) << describe(data) << ": checksum packet has no run longer than one bit"; + EXPECT_FALSE(decode(timings).has_value()) << describe(data); + } +} + +// A SYNC frame carries seven packets; dropping a middle one leaves a well formed six packet frame whose +// command still claims seven, so it must be rejected. +TEST(IRA211ProtocolTest, RejectsAFrameOfTheWrongLengthForItsCommand) { + const IRA211Data data = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_HIGH); + RawTimings timings = encode(data); + ASSERT_TRUE(decode(timings).has_value()); + const auto [begin, end] = packet_run_range(timings, 3); // the tenths byte + ASSERT_GT(begin, PREAMBLE_ITEMS - 1); + ASSERT_GT(end, begin); + timings.erase(timings.begin() + begin, timings.begin() + end); + EXPECT_FALSE(decode(timings).has_value()); +} + +// transmit_state() sends POWER then SYNC back to back, so a receiver with a long idle sees both in one capture. +// The gap may be captured a little short; it must still end the first frame. +TEST(IRA211ProtocolTest, DecodesTheFirstOfTwoFramesWithAShortenedGap) { + const IRA211Data power = + make_data(IRA211Command::IRA211_COMMAND_POWER, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW); + const IRA211Data sync = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW); + RawTimings timings = encode(power); + timings.back() = -int32_t(HEADER_US - 100); + const RawTimings second = encode(sync); + timings.insert(timings.end(), second.begin(), second.end()); + expect_round_trip(timings, power); +} + +// Values are encoded as given, so a frame with an out-of-range field still gets a valid checksum. +TEST(IRA211ProtocolTest, RejectsAnOutOfRangeTemperature) { + for (uint8_t half_degrees : {uint8_t(9), uint8_t(71), uint8_t(72), uint8_t(255)}) { + IRA211Data data = make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, + IRA211Fan::IRA211_FAN_AUTO); + data.half_degrees = half_degrees; + EXPECT_FALSE(decode(encode(data)).has_value()) << "half degrees " << int(half_degrees); + } +} + +TEST(IRA211ProtocolTest, RejectsAnUnknownModeOrFan) { + IRA211Data mode = + make_data(IRA211Command::IRA211_COMMAND_MODE, 20.5f, static_cast(1), IRA211Fan::IRA211_FAN_AUTO); + EXPECT_FALSE(decode(encode(mode)).has_value()); + IRA211Data fan = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, static_cast(1)); + EXPECT_FALSE(decode(encode(fan)).has_value()); +} + +TEST(IRA211ProtocolTest, RejectsAForeignFrame) { + const RawTimings nec_like = {9000, -4500, 560, -560, 560, -1690, 560, -560, 560}; + EXPECT_FALSE(decode(nec_like).has_value()); + EXPECT_FALSE(decode({}).has_value()); +} + +TEST(IRA211ProtocolTest, EqualityIgnoresFieldsTheCommandDoesNotCarry) { + const IRA211Data temp_up_auto = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.5f, + IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + const IRA211Data temp_up_high = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.5f, IRA211Mode::IRA211_MODE_TIMER, + IRA211Fan::IRA211_FAN_HIGH); + EXPECT_TRUE(temp_up_auto == temp_up_high); + const IRA211Data temp_down = make_data(IRA211Command::IRA211_COMMAND_TEMP_DOWN, 20.5f, + IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + EXPECT_FALSE(temp_up_auto == temp_down); + const IRA211Data temp_up_colder = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.0f, + IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + EXPECT_FALSE(temp_up_auto == temp_up_colder); + + const IRA211Data mode_low = + make_data(IRA211Command::IRA211_COMMAND_MODE, 5.0f, IRA211Mode::IRA211_MODE_TIMER, IRA211Fan::IRA211_FAN_LOW); + const IRA211Data mode_high = + make_data(IRA211Command::IRA211_COMMAND_MODE, 35.0f, IRA211Mode::IRA211_MODE_TIMER, IRA211Fan::IRA211_FAN_HIGH); + EXPECT_TRUE(mode_low == mode_high); + + const IRA211Data sync_auto = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO); + const IRA211Data sync_high = + make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_HIGH); + EXPECT_FALSE(sync_auto == sync_high); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index cd01690344..6aa7ae37bb 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -192,3 +192,8 @@ on_hob2hood: - logger.log: format: "on_hob2hood: %u" args: ["x.command"] +on_ira211: + then: + - logger.log: + format: "on_ira211: %u %.1f" + args: ["static_cast(x.command)", "x.get_temperature()"] diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index d2b135f034..5062319040 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -43,6 +43,13 @@ binary_sensor: name: Hob2Hood Light On hob2hood: command: light_on + - platform: remote_receiver + name: IRA211 Sync + ira211: + command: sync + temperature: 21.5 + mode: comfort + fan_mode: low - platform: remote_receiver name: MagiQuest Wand magiquest: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index dff4e8f9c5..b3583ffe12 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -92,6 +92,14 @@ button: - remote_transmitter.transmit_toshiba_ac: rc_code_1: 0xB24DBF4050AF rc_code_2: 0xD5660001003C + - platform: template + name: IRA211 + on_press: + - remote_transmitter.transmit_ira211: + command: sync + temperature: 22.5 + mode: timer + fan_mode: high - platform: template name: Sony on_press: diff --git a/tests/components/siemens_ira211/common.yaml b/tests/components/siemens_ira211/common.yaml new file mode 100644 index 0000000000..fc2c53f967 --- /dev/null +++ b/tests/components/siemens_ira211/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: siemens_ira211 + name: Siemens IRA211 + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/siemens_ira211/test.esp32-idf.yaml b/tests/components/siemens_ira211/test.esp32-idf.yaml new file mode 100644 index 0000000000..b217e0f0d9 --- /dev/null +++ b/tests/components/siemens_ira211/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 + siemens_ira211: !include common.yaml diff --git a/tests/components/siemens_ira211/test.esp8266-ard.yaml b/tests/components/siemens_ira211/test.esp8266-ard.yaml new file mode 100644 index 0000000000..b5b3d45c10 --- /dev/null +++ b/tests/components/siemens_ira211/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + siemens_ira211: !include common.yaml From 833c6634ad2b7f16d82060dd01cf74e9c4298dd3 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Mon, 12 Oct 2026 20:44:51 +0000 Subject: [PATCH 203/268] [lis2dw12] Add ST LIS2DW12 accelerometer component (#14780) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/lis2dw12/__init__.py | 181 +++++++++ esphome/components/lis2dw12/binary_sensor.py | 43 +++ esphome/components/lis2dw12/lis2dw12.cpp | 350 ++++++++++++++++++ esphome/components/lis2dw12/lis2dw12.h | 198 ++++++++++ esphome/components/lis2dw12/sensor.py | 43 +++ esphome/components/lis2dw12/text_sensor.py | 27 ++ esphome/components/lis2dw12_i2c/__init__.py | 35 ++ .../components/lis2dw12_i2c/lis2dw12_i2c.cpp | 13 + .../components/lis2dw12_i2c/lis2dw12_i2c.h | 17 + esphome/components/lis2dw12_spi/__init__.py | 37 ++ .../components/lis2dw12_spi/lis2dw12_spi.cpp | 34 ++ .../components/lis2dw12_spi/lis2dw12_spi.h | 18 + tests/components/lis2dw12_i2c/common.yaml | 60 +++ .../lis2dw12_i2c/test.esp32-idf.yaml | 3 + .../lis2dw12_i2c/test.esp8266-ard.yaml | 3 + .../lis2dw12_i2c/test.rp2040-ard.yaml | 3 + tests/components/lis2dw12_spi/common.yaml | 61 +++ .../lis2dw12_spi/test.esp32-idf.yaml | 3 + .../lis2dw12_spi/test.esp8266-ard.yaml | 3 + .../lis2dw12_spi/test.rp2040-ard.yaml | 3 + 21 files changed, 1138 insertions(+) create mode 100644 esphome/components/lis2dw12/__init__.py create mode 100644 esphome/components/lis2dw12/binary_sensor.py create mode 100644 esphome/components/lis2dw12/lis2dw12.cpp create mode 100644 esphome/components/lis2dw12/lis2dw12.h create mode 100644 esphome/components/lis2dw12/sensor.py create mode 100644 esphome/components/lis2dw12/text_sensor.py create mode 100644 esphome/components/lis2dw12_i2c/__init__.py create mode 100644 esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp create mode 100644 esphome/components/lis2dw12_i2c/lis2dw12_i2c.h create mode 100644 esphome/components/lis2dw12_spi/__init__.py create mode 100644 esphome/components/lis2dw12_spi/lis2dw12_spi.cpp create mode 100644 esphome/components/lis2dw12_spi/lis2dw12_spi.h create mode 100644 tests/components/lis2dw12_i2c/common.yaml create mode 100644 tests/components/lis2dw12_i2c/test.esp32-idf.yaml create mode 100644 tests/components/lis2dw12_i2c/test.esp8266-ard.yaml create mode 100644 tests/components/lis2dw12_i2c/test.rp2040-ard.yaml create mode 100644 tests/components/lis2dw12_spi/common.yaml create mode 100644 tests/components/lis2dw12_spi/test.esp32-idf.yaml create mode 100644 tests/components/lis2dw12_spi/test.esp8266-ard.yaml create mode 100644 tests/components/lis2dw12_spi/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index db9dcccd7f..192147f290 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -311,6 +311,9 @@ esphome/components/libretiny_pwm/* @kuba2k2 esphome/components/light/* @esphome/core esphome/components/lightwaverf/* @max246 esphome/components/lilygo_t5_47/touchscreen/* @jesserockz +esphome/components/lis2dw12/* @latonita +esphome/components/lis2dw12_i2c/* @latonita +esphome/components/lis2dw12_spi/* @latonita esphome/components/lm75b/* @beormund esphome/components/ln882h_ble/* @Bl00d-B0b esphome/components/ln882h_ble_tracker/* @Bl00d-B0b diff --git a/esphome/components/lis2dw12/__init__.py b/esphome/components/lis2dw12/__init__.py new file mode 100644 index 0000000000..fcd885e5db --- /dev/null +++ b/esphome/components/lis2dw12/__init__.py @@ -0,0 +1,181 @@ +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_CALIBRATION, + CONF_ID, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_X, + CONF_OFFSET_Y, + CONF_OFFSET_Z, + CONF_POWER_MODE, + CONF_RANGE, + CONF_SWAP_XY, + CONF_TRANSFORM, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DOMAIN = "lis2dw12" + +CONF_LIS2DW12_ID = "lis2dw12_id" + +CONF_MIRROR_Z = "mirror_z" +CONF_ON_ACTIVE = "on_active" +CONF_ON_DOUBLE_TAP = "on_double_tap" +CONF_ON_FREEFALL = "on_freefall" +CONF_ON_ORIENTATION = "on_orientation" +CONF_ON_TAP = "on_tap" + +CONF_LOW_NOISE = "low_noise" +CONF_FILTER_BANDWIDTH = "filter_bandwidth" +CONF_OUTPUT_DATA_RATE = "output_data_rate" + +lis2dw12_ns = cg.esphome_ns.namespace("lis2dw12") +LIS2DW12Component = lis2dw12_ns.class_("LIS2DW12Component", cg.PollingComponent) + +LIS2DW12Range = lis2dw12_ns.enum("Range", True) +RANGES = { + "2G": LIS2DW12Range.RANGE_2G, + "4G": LIS2DW12Range.RANGE_4G, + "8G": LIS2DW12Range.RANGE_8G, + "16G": LIS2DW12Range.RANGE_16G, +} + +LIS2DW12PowerMode = lis2dw12_ns.enum("PowerMode", True) +POWER_MODES = { + "high_performance": LIS2DW12PowerMode.HIGH_PERF, + "low_power_1": LIS2DW12PowerMode.LOW_POWER_1, + "low_power_2": LIS2DW12PowerMode.LOW_POWER_2, + "low_power_3": LIS2DW12PowerMode.LOW_POWER_3, + "low_power_4": LIS2DW12PowerMode.LOW_POWER_4, +} + +LIS2DW12DataRate = lis2dw12_ns.enum("DataRate", True) +DATA_RATES = { + "1.6Hz": LIS2DW12DataRate.RATE_1_6HZ, + "12.5Hz": LIS2DW12DataRate.RATE_12_5HZ, + "25Hz": LIS2DW12DataRate.RATE_25HZ, + "50Hz": LIS2DW12DataRate.RATE_50HZ, + "100Hz": LIS2DW12DataRate.RATE_100HZ, + "200Hz": LIS2DW12DataRate.RATE_200HZ, + "400Hz": LIS2DW12DataRate.RATE_400HZ, + "800Hz": LIS2DW12DataRate.RATE_800HZ, + "1600Hz": LIS2DW12DataRate.RATE_1600HZ, +} + +LIS2DW12FilterBandwidth = lis2dw12_ns.enum("FilterBandwidth", True) +FILTER_BANDWIDTHS = { + "odr_div_2": LIS2DW12FilterBandwidth.BW_ODR_DIV_2, + "odr_div_4": LIS2DW12FilterBandwidth.BW_ODR_DIV_4, + "odr_div_10": LIS2DW12FilterBandwidth.BW_ODR_DIV_10, + "odr_div_20": LIS2DW12FilterBandwidth.BW_ODR_DIV_20, +} + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_TAP, "add_on_tap_callback"), + automation.CallbackAutomation(CONF_ON_DOUBLE_TAP, "add_on_double_tap_callback"), + automation.CallbackAutomation(CONF_ON_FREEFALL, "add_on_freefall_callback"), + automation.CallbackAutomation(CONF_ON_ACTIVE, "add_on_active_callback"), + automation.CallbackAutomation(CONF_ON_ORIENTATION, "add_on_orientation_callback"), +) + +_OFFSET = cv.float_range(min=-4.5, max=4.5) + +# The ODR code means different rates per power mode: 0001 is 1.6 Hz only in low-power modes +# (12.5 Hz in high performance) and every code above 200 Hz runs at 200 Hz in low-power modes +_LOW_POWER_ONLY_RATES = ("1.6Hz",) +_HIGH_PERF_ONLY_RATES = ("400Hz", "800Hz", "1600Hz") + + +def validate_data_rate(config: ConfigType) -> ConfigType: + """Reject rates the chip cannot run in the chosen power mode; apply after extending the base schema.""" + rate = str(config[CONF_OUTPUT_DATA_RATE]) + mode = str(config[CONF_POWER_MODE]) + high_perf = mode == "high_performance" + if (high_perf and rate in _LOW_POWER_ONLY_RATES) or ( + not high_perf and rate in _HIGH_PERF_ONLY_RATES + ): + raise cv.Invalid( + f"{CONF_OUTPUT_DATA_RATE} {rate} is not available with {CONF_POWER_MODE} {mode}", + [CONF_OUTPUT_DATA_RATE], + ) + return config + + +CONFIG_SCHEMA_BASE = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LIS2DW12Component), + cv.Optional(CONF_RANGE, default="2G"): cv.enum(RANGES, upper=True), + cv.Optional(CONF_POWER_MODE, default="high_performance"): cv.enum( + POWER_MODES, lower=True + ), + cv.Optional(CONF_OUTPUT_DATA_RATE, default="100Hz"): cv.enum(DATA_RATES), + cv.Optional(CONF_FILTER_BANDWIDTH, default="odr_div_2"): cv.enum( + FILTER_BANDWIDTHS, lower=True + ), + cv.Optional(CONF_LOW_NOISE, default=False): cv.boolean, + cv.Optional(CONF_CALIBRATION): cv.Schema( + { + cv.Optional(CONF_OFFSET_X, default=0): _OFFSET, + cv.Optional(CONF_OFFSET_Y, default=0): _OFFSET, + cv.Optional(CONF_OFFSET_Z, default=0): _OFFSET, + } + ), + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Z, default=False): cv.boolean, + cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, + } + ), + cv.Optional(CONF_ON_TAP): automation.validate_automation({}), + cv.Optional(CONF_ON_DOUBLE_TAP): automation.validate_automation({}), + cv.Optional(CONF_ON_FREEFALL): automation.validate_automation({}), + cv.Optional(CONF_ON_ACTIVE): automation.validate_automation({}), + cv.Optional(CONF_ON_ORIENTATION): automation.validate_automation({}), + } +).extend(cv.polling_component_schema("10s")) + +LIS2DW12_SENSOR_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LIS2DW12_ID): cv.use_id(LIS2DW12Component), + } +) + + +async def to_code_base(config: ConfigType) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_range(config[CONF_RANGE])) + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) + cg.add(var.set_output_data_rate(config[CONF_OUTPUT_DATA_RATE])) + cg.add(var.set_filter_bandwidth(config[CONF_FILTER_BANDWIDTH])) + cg.add(var.set_low_noise(config[CONF_LOW_NOISE])) + + if transform := config.get(CONF_TRANSFORM): + cg.add( + var.set_transform( + transform[CONF_MIRROR_X], + transform[CONF_MIRROR_Y], + transform[CONF_MIRROR_Z], + transform[CONF_SWAP_XY], + ) + ) + + if calibration_config := config.get(CONF_CALIBRATION): + cg.add( + var.set_offset( + calibration_config[CONF_OFFSET_X], + calibration_config[CONF_OFFSET_Y], + calibration_config[CONF_OFFSET_Z], + ) + ) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + return var diff --git a/esphome/components/lis2dw12/binary_sensor.py b/esphome/components/lis2dw12/binary_sensor.py new file mode 100644 index 0000000000..3ff0e8f15c --- /dev/null +++ b/esphome/components/lis2dw12/binary_sensor.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ACTIVE, CONF_NAME, DEVICE_CLASS_VIBRATION, ICON_VIBRATE +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +CONF_DOUBLE_TAP = "double_tap" +CONF_FREEFALL = "freefall" +CONF_TAP = "tap" + +ICON_DOUBLE_TAP = "mdi:gesture-double-tap" +ICON_FREEFALL = "mdi:parachute" +ICON_TAP = "mdi:gesture-tap" + +EVENT_SENSORS = (CONF_TAP, CONF_DOUBLE_TAP, CONF_FREEFALL, CONF_ACTIVE) +ICONS = (ICON_TAP, ICON_DOUBLE_TAP, ICON_FREEFALL, ICON_VIBRATE) + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + { + cv.Optional(event): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_VIBRATION, + icon=icon, + ), + key=CONF_NAME, + ) + for event, icon in zip(EVENT_SENSORS, ICONS, strict=True) + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_TAP, hub.set_tap_binary_sensor) + await sensors(CONF_DOUBLE_TAP, hub.set_double_tap_binary_sensor) + await sensors(CONF_FREEFALL, hub.set_freefall_binary_sensor) + await sensors(CONF_ACTIVE, hub.set_active_binary_sensor) diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp new file mode 100644 index 0000000000..38065aa785 --- /dev/null +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -0,0 +1,350 @@ +#include "lis2dw12.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12 { + +ESPHOME_LOG_TAG(TAG, "lis2dw12"); + +static constexpr uint8_t LIS2DW12_WHO_AM_I_VALUE = 0x44; +static constexpr float GRAVITY_EARTH = 9.80665f; +static constexpr uint32_t EVENT_COOLDOWN_MS = 500; +static constexpr uint32_t POLL_INTERVAL_MS = 50; +static constexpr uint8_t SIXD_POSITION_MASK = 0x3F; // XL..ZH, leaving out D6D_IA + +static const LogString *power_mode_to_string(PowerMode mode) { + switch (mode) { + case PowerMode::HIGH_PERF: + return LOG_STR("High Performance"); + case PowerMode::LOW_POWER_1: + return LOG_STR("Low Power 1 (12-bit)"); + case PowerMode::LOW_POWER_2: + return LOG_STR("Low Power 2"); + case PowerMode::LOW_POWER_3: + return LOG_STR("Low Power 3"); + case PowerMode::LOW_POWER_4: + return LOG_STR("Low Power 4"); + } + return LOG_STR("Unknown"); +} + +static const LogString *range_to_string(Range range) { + switch (range) { + case Range::RANGE_2G: + return LOG_STR("±2g"); + case Range::RANGE_4G: + return LOG_STR("±4g"); + case Range::RANGE_8G: + return LOG_STR("±8g"); + case Range::RANGE_16G: + return LOG_STR("±16g"); + } + return LOG_STR("Unknown"); +} + +static const LogString *data_rate_to_string(DataRate rate) { + switch (rate) { + case DataRate::RATE_1_6HZ: + return LOG_STR("1.6 Hz"); + case DataRate::RATE_12_5HZ: + return LOG_STR("12.5 Hz"); + case DataRate::RATE_25HZ: + return LOG_STR("25 Hz"); + case DataRate::RATE_50HZ: + return LOG_STR("50 Hz"); + case DataRate::RATE_100HZ: + return LOG_STR("100 Hz"); + case DataRate::RATE_200HZ: + return LOG_STR("200 Hz"); + case DataRate::RATE_400HZ: + return LOG_STR("400 Hz"); + case DataRate::RATE_800HZ: + return LOG_STR("800 Hz"); + case DataRate::RATE_1600HZ: + return LOG_STR("1600 Hz"); + } + return LOG_STR("Unknown"); +} + +static const LogString *filter_bandwidth_to_string(FilterBandwidth bw) { + switch (bw) { + case FilterBandwidth::BW_ODR_DIV_2: + return LOG_STR("ODR/2"); + case FilterBandwidth::BW_ODR_DIV_4: + return LOG_STR("ODR/4"); + case FilterBandwidth::BW_ODR_DIV_10: + return LOG_STR("ODR/10"); + case FilterBandwidth::BW_ODR_DIV_20: + return LOG_STR("ODR/20"); + } + return LOG_STR("Unknown"); +} + +void LIS2DW12Component::setup() { + uint8_t who_am_i{0}; + if (!this->read_byte(static_cast(RegisterMap::WHO_AM_I), &who_am_i) || who_am_i != LIS2DW12_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "WHO_AM_I check failed. Got 0x%02X, expected 0x%02X", who_am_i, LIS2DW12_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + // Soft reset + if (!this->write_reg_(RegisterMap::CTRL2, 0x40)) { + this->mark_failed(LOG_STR("Soft reset failed")); + return; + } + delay(1); // NOLINT - wait for reset to complete + + uint8_t ctrl2{0}; + if (!this->read_byte(static_cast(RegisterMap::CTRL2), &ctrl2) || (ctrl2 & 0x40)) { + this->mark_failed(LOG_STR("Soft reset did not complete")); + return; + } + + if (!this->configure_registers_()) { + this->mark_failed(LOG_STR("Register configuration failed")); + return; + } + + // 0.244 mg/LSB at ±2g (14-bit), doubling per range step; 12-bit LP1 is 4x coarser + const bool lp1 = this->power_mode_ == PowerMode::LOW_POWER_1; + const uint8_t range_idx = static_cast(this->range_); + const float mg = 0.244f * (1u << range_idx) * (lp1 ? 4 : 1); + this->scale_ = mg * 0.001f * GRAVITY_EARTH; + this->shift_ = lp1 ? 4 : 2; + + bool events_used = !this->tap_callback_.empty() || !this->double_tap_callback_.empty() || + !this->freefall_callback_.empty() || !this->active_callback_.empty() || + !this->orientation_callback_.empty(); +#ifdef USE_BINARY_SENSOR + for (auto *sensor : this->event_binary_sensors_) { + if (sensor != nullptr) { + sensor->publish_initial_state(false); + events_used = true; + } + } +#endif +#ifdef USE_TEXT_SENSOR + RegSixdSrc sixd; + if (this->orientation_text_sensor_ != nullptr && + this->read_byte(static_cast(RegisterMap::SIXD_SRC), &sixd.raw)) { + this->publish_orientation_(sixd); + events_used = true; + } +#endif + if (!events_used) { + this->disable_loop(); // nothing consumes the events, so skip polling the chip + } +} + +bool LIS2DW12Component::configure_registers_() { + // CTRL1: ODR[7:4], MODE[3:2] (00=low power, 01=high performance), LP_MODE[1:0] (LP1..LP4) + const uint8_t odr_bits = static_cast(this->data_rate_) << 4; + uint8_t ctrl1; + if (this->power_mode_ == PowerMode::HIGH_PERF) { + ctrl1 = odr_bits | 0x04; + } else { + ctrl1 = odr_bits | ((static_cast(this->power_mode_) - 1) & 0x03); + } + + // CTRL6: BW_FILT[7:6], FS[5:4], LOW_NOISE[2] + uint8_t ctrl6 = (static_cast(this->filter_bandwidth_) << 6) | (static_cast(this->range_) << 4); + if (this->low_noise_) { + ctrl6 |= 0x04; + } + + return this->write_reg_(RegisterMap::CTRL2, 0x0C) && // BDU, IF_ADD_INC + this->write_reg_(RegisterMap::CTRL1, ctrl1) && // ODR and power mode + this->write_reg_(RegisterMap::CTRL6, ctrl6) && // bandwidth, range, low noise + this->write_reg_(RegisterMap::TAP_THS_X, 0x29) && // 6D_THS=70 degrees, TAP_THSX=9 + this->write_reg_(RegisterMap::TAP_THS_Y, 0x09) && // TAP_THSY=9 + this->write_reg_(RegisterMap::TAP_THS_Z, 0xE9) && // TAP_THSZ=9, tap on X/Y/Z + this->write_reg_(RegisterMap::INT_DUR, 0x76) && // LATENCY=7, QUIET=1, SHOCK=2 + this->write_reg_(RegisterMap::WAKE_UP_THS, 0x82) && // double tap enabled, WU_THS=2 + this->write_reg_(RegisterMap::WAKE_UP_DUR, 0x00) && // no wake/sleep durations + this->write_reg_(RegisterMap::FREE_FALL, 0x33) && // FF_DUR=6, FF_THS=312 mg + this->write_reg_(RegisterMap::CTRL4_INT1, 0xF8) && // 6D, single tap, wake-up, free-fall, double tap to INT1 + this->write_reg_(RegisterMap::CTRL3, 0x10) && // LIR: latched interrupts + this->write_reg_(RegisterMap::CTRL7, 0x20); // INTERRUPTS_ENABLE +} + +void LIS2DW12Component::dump_config() { + ESP_LOGCONFIG(TAG, "LIS2DW12:"); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + ESP_LOGCONFIG(TAG, + " Power Mode: %s\n" + " Data Rate: %s\n" + " Range: %s\n" + " Filter Bandwidth: %s\n" + " Low Noise: %s\n" + " Offsets: {%.3f m/s², %.3f m/s², %.3f m/s²}\n" + " Transform: {mirror_x=%s, mirror_y=%s, mirror_z=%s, swap_xy=%s}", + LOG_STR_ARG(power_mode_to_string(this->power_mode_)), + LOG_STR_ARG(data_rate_to_string(this->data_rate_)), LOG_STR_ARG(range_to_string(this->range_)), + LOG_STR_ARG(filter_bandwidth_to_string(this->filter_bandwidth_)), YESNO(this->low_noise_), + this->offset_x_, this->offset_y_, this->offset_z_, YESNO(this->mirror_x_), YESNO(this->mirror_y_), + YESNO(this->mirror_z_), YESNO(this->swap_xy_)); + LOG_UPDATE_INTERVAL(this); + +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Tap", this->event_binary_sensors_[EVENT_TAP]); + LOG_BINARY_SENSOR(" ", "Double Tap", this->event_binary_sensors_[EVENT_DOUBLE_TAP]); + LOG_BINARY_SENSOR(" ", "Freefall", this->event_binary_sensors_[EVENT_FREEFALL]); + LOG_BINARY_SENSOR(" ", "Active", this->event_binary_sensors_[EVENT_ACTIVE]); +#endif + +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Acceleration X", this->acceleration_x_sensor_); + LOG_SENSOR(" ", "Acceleration Y", this->acceleration_y_sensor_); + LOG_SENSOR(" ", "Acceleration Z", this->acceleration_z_sensor_); +#endif + +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Orientation", this->orientation_text_sensor_); +#endif +} + +bool LIS2DW12Component::read_acceleration_data_(float &x, float &y, float &z) { + uint8_t accel_data[6]; + if (!this->read_bytes(static_cast(RegisterMap::OUT_X_L), accel_data, 6)) { + return false; + } + + int16_t raw_x = static_cast(encode_uint16(accel_data[1], accel_data[0])) >> this->shift_; + int16_t raw_y = static_cast(encode_uint16(accel_data[3], accel_data[2])) >> this->shift_; + int16_t raw_z = static_cast(encode_uint16(accel_data[5], accel_data[4])) >> this->shift_; + + if (this->swap_xy_) { + std::swap(raw_x, raw_y); + } + x = (this->mirror_x_ ? -raw_x : raw_x) * this->scale_ + this->offset_x_; + y = (this->mirror_y_ ? -raw_y : raw_y) * this->scale_ + this->offset_y_; + z = (this->mirror_z_ ? -raw_z : raw_z) * this->scale_ + this->offset_z_; + return true; +} + +#ifdef USE_TEXT_SENSOR +static const char *orientation_to_string(RegSixdSrc sixd) { + if (sixd.xh) + return "X Up"; + if (sixd.xl) + return "X Down"; + if (sixd.yh) + return "Y Up"; + if (sixd.yl) + return "Y Down"; + if (sixd.zh) + return "Z Up"; + if (sixd.zl) + return "Z Down"; + return "Unknown"; +} + +void LIS2DW12Component::publish_orientation_(RegSixdSrc sixd) { + const uint8_t position = sixd.raw & SIXD_POSITION_MASK; + if (this->orientation_text_sensor_ == nullptr || position == this->sixd_last_) { + return; + } + this->sixd_last_ = position; + this->orientation_text_sensor_->publish_state(orientation_to_string(sixd)); +} +#endif + +void LIS2DW12Component::handle_event_(Event event, bool active, uint32_t now, LazyCallbackManager &callback) { + const uint8_t bit = 1 << event; + bool state; + if (active) { + if (now - this->last_event_ms_[event] <= EVENT_COOLDOWN_MS) { + return; + } + this->last_event_ms_[event] = now; + callback.call(); + if (this->events_on_ & bit) { + return; + } + this->events_on_ |= bit; + state = true; + } else { + if (!(this->events_on_ & bit) || now - this->last_event_ms_[event] <= EVENT_COOLDOWN_MS) { + return; + } + this->events_on_ &= ~bit; + state = false; + } +#ifdef USE_BINARY_SENSOR + if (this->event_binary_sensors_[event] != nullptr) { + this->event_binary_sensors_[event]->publish_state(state); + } +#endif +} + +void LIS2DW12Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ms_ < POLL_INTERVAL_MS) { + return; + } + this->last_poll_ms_ = now; + + // SIXD_SRC and ALL_INT_SRC are adjacent, so one read captures both before any latch clears + uint8_t regs[2]; + if (!this->read_bytes(static_cast(RegisterMap::SIXD_SRC), regs, sizeof(regs))) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + RegSixdSrc sixd; + sixd.raw = regs[0]; + RegAllIntSrc src; + src.raw = regs[1]; + + this->handle_event_(EVENT_TAP, src.single_tap, now, this->tap_callback_); + this->handle_event_(EVENT_DOUBLE_TAP, src.double_tap, now, this->double_tap_callback_); + this->handle_event_(EVENT_FREEFALL, src.ff_ia, now, this->freefall_callback_); + this->handle_event_(EVENT_ACTIVE, src.wu_ia, now, this->active_callback_); + + if (sixd.d6d_ia) { + this->orientation_callback_.call(); +#ifdef USE_TEXT_SENSOR + this->publish_orientation_(sixd); +#endif + } +} + +void LIS2DW12Component::update() { + float x, y, z; + if (!this->read_acceleration_data_(x, y, z)) { + this->status_set_warning(); + return; + } + + ESP_LOGV(TAG, "Acceleration: {x = %+1.3f m/s², y = %+1.3f m/s², z = %+1.3f m/s²}", x, y, z); + +#ifdef USE_SENSOR + if (this->acceleration_x_sensor_ != nullptr) + this->acceleration_x_sensor_->publish_state(x); + if (this->acceleration_y_sensor_ != nullptr) + this->acceleration_y_sensor_->publish_state(y); + if (this->acceleration_z_sensor_ != nullptr) + this->acceleration_z_sensor_->publish_state(z); +#endif + + this->status_clear_warning(); +} + +void LIS2DW12Component::set_offset(float offset_x, float offset_y, float offset_z) { + this->offset_x_ = offset_x; + this->offset_y_ = offset_y; + this->offset_z_ = offset_z; +} + +void LIS2DW12Component::set_transform(bool mirror_x, bool mirror_y, bool mirror_z, bool swap_xy) { + this->mirror_x_ = mirror_x; + this->mirror_y_ = mirror_y; + this->mirror_z_ = mirror_z; + this->swap_xy_ = swap_xy; +} + +} // namespace esphome::lis2dw12 diff --git a/esphome/components/lis2dw12/lis2dw12.h b/esphome/components/lis2dw12/lis2dw12.h new file mode 100644 index 0000000000..11c1e6b862 --- /dev/null +++ b/esphome/components/lis2dw12/lis2dw12.h @@ -0,0 +1,198 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif + +namespace esphome::lis2dw12 { + +// LIS2DW12 Register Map +enum class RegisterMap : uint8_t { + WHO_AM_I = 0x0F, + CTRL1 = 0x20, + CTRL2 = 0x21, + CTRL3 = 0x22, + CTRL4_INT1 = 0x23, + CTRL6 = 0x25, + OUT_X_L = 0x28, + TAP_THS_X = 0x30, + TAP_THS_Y = 0x31, + TAP_THS_Z = 0x32, + INT_DUR = 0x33, + WAKE_UP_THS = 0x34, + WAKE_UP_DUR = 0x35, + FREE_FALL = 0x36, + SIXD_SRC = 0x3A, + ALL_INT_SRC = 0x3B, + CTRL7 = 0x3F, +}; + +enum class Range : uint8_t { + RANGE_2G = 0b00, + RANGE_4G = 0b01, + RANGE_8G = 0b10, + RANGE_16G = 0b11, +}; + +enum class PowerMode : uint8_t { + HIGH_PERF = 0, + LOW_POWER_1 = 1, + LOW_POWER_2 = 2, + LOW_POWER_3 = 3, + LOW_POWER_4 = 4, +}; + +enum class DataRate : uint8_t { + RATE_1_6HZ = 0b0001, + RATE_12_5HZ = 0b0010, + RATE_25HZ = 0b0011, + RATE_50HZ = 0b0100, + RATE_100HZ = 0b0101, + RATE_200HZ = 0b0110, + RATE_400HZ = 0b0111, + RATE_800HZ = 0b1000, + RATE_1600HZ = 0b1001, +}; + +enum class FilterBandwidth : uint8_t { + BW_ODR_DIV_2 = 0b00, + BW_ODR_DIV_4 = 0b01, + BW_ODR_DIV_10 = 0b10, + BW_ODR_DIV_20 = 0b11, +}; + +// ALL_INT_SRC register (0x3B) +union RegAllIntSrc { + struct { + bool ff_ia : 1; + bool wu_ia : 1; + bool single_tap : 1; + bool double_tap : 1; + bool d6d_ia : 1; + bool sleep_change_ia : 1; + uint8_t reserved : 2; + } __attribute__((packed)); + uint8_t raw{0}; +}; + +// SIXD_SRC register (0x3A) +union RegSixdSrc { + struct { + bool xl : 1; + bool xh : 1; + bool yl : 1; + bool yh : 1; + bool zl : 1; + bool zh : 1; + bool d6d_ia : 1; + uint8_t reserved : 1; + } __attribute__((packed)); + uint8_t raw{0}; +}; + +// Debounced events, also the bit index into events_on_ +enum Event : uint8_t { + EVENT_TAP = 0, + EVENT_DOUBLE_TAP, + EVENT_FREEFALL, + EVENT_ACTIVE, + EVENT_COUNT, +}; + +class LIS2DW12Component : public PollingComponent { + public: + void setup() override; + void dump_config() override; + void loop() override; + void update() override; + + void set_power_mode(PowerMode mode) { this->power_mode_ = mode; } + void set_low_noise(bool low_noise) { this->low_noise_ = low_noise; } + void set_output_data_rate(DataRate rate) { this->data_rate_ = rate; } + void set_range(Range range) { this->range_ = range; } + void set_filter_bandwidth(FilterBandwidth bw) { this->filter_bandwidth_ = bw; } + void set_offset(float offset_x, float offset_y, float offset_z); + void set_transform(bool mirror_x, bool mirror_y, bool mirror_z, bool swap_xy); + + bool read_byte(uint8_t a_register, uint8_t *data) { return this->read_bytes(a_register, data, 1); } + virtual bool write_byte(uint8_t a_register, uint8_t data) = 0; + virtual bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; + +#ifdef USE_BINARY_SENSOR + void set_tap_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_TAP] = s; } + void set_double_tap_binary_sensor(binary_sensor::BinarySensor *s) { + this->event_binary_sensors_[EVENT_DOUBLE_TAP] = s; + } + void set_freefall_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_FREEFALL] = s; } + void set_active_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_ACTIVE] = s; } +#endif + +#ifdef USE_SENSOR + SUB_SENSOR(acceleration_x) + SUB_SENSOR(acceleration_y) + SUB_SENSOR(acceleration_z) +#endif + +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(orientation) +#endif + + template void add_on_tap_callback(F &&callback) { this->tap_callback_.add(std::forward(callback)); } + template void add_on_double_tap_callback(F &&callback) { + this->double_tap_callback_.add(std::forward(callback)); + } + template void add_on_freefall_callback(F &&callback) { + this->freefall_callback_.add(std::forward(callback)); + } + template void add_on_active_callback(F &&callback) { + this->active_callback_.add(std::forward(callback)); + } + template void add_on_orientation_callback(F &&callback) { + this->orientation_callback_.add(std::forward(callback)); + } + + protected: + bool write_reg_(RegisterMap reg, uint8_t value) { return this->write_byte(static_cast(reg), value); } + bool configure_registers_(); + bool read_acceleration_data_(float &x, float &y, float &z); + void handle_event_(Event event, bool active, uint32_t now, LazyCallbackManager &callback); +#ifdef USE_TEXT_SENSOR + void publish_orientation_(RegSixdSrc sixd); +#endif + + float offset_x_{0}, offset_y_{0}, offset_z_{0}; + float scale_{0}; // m/s² per LSB after shift_ + uint32_t last_event_ms_[EVENT_COUNT]{}; + uint32_t last_poll_ms_{0}; +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *event_binary_sensors_[EVENT_COUNT]{}; +#endif + LazyCallbackManager tap_callback_; + LazyCallbackManager double_tap_callback_; + LazyCallbackManager freefall_callback_; + LazyCallbackManager active_callback_; + LazyCallbackManager orientation_callback_; + + PowerMode power_mode_{PowerMode::HIGH_PERF}; + DataRate data_rate_{DataRate::RATE_100HZ}; + Range range_{Range::RANGE_2G}; + FilterBandwidth filter_bandwidth_{FilterBandwidth::BW_ODR_DIV_2}; + bool low_noise_{false}; + bool mirror_x_{false}, mirror_y_{false}, mirror_z_{false}, swap_xy_{false}; + uint8_t shift_{2}; // data is left-justified: 14-bit >> 2, 12-bit (LP1) >> 4 + uint8_t events_on_{0}; // bit per Event: binary sensor currently published on +#ifdef USE_TEXT_SENSOR + uint8_t sixd_last_{0xFF}; // position bits only; 0xFF cannot occur, so the first read publishes +#endif +}; + +} // namespace esphome::lis2dw12 diff --git a/esphome/components/lis2dw12/sensor.py b/esphome/components/lis2dw12/sensor.py new file mode 100644 index 0000000000..9f4a4e570f --- /dev/null +++ b/esphome/components/lis2dw12/sensor.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCELERATION_X, + CONF_ACCELERATION_Y, + CONF_ACCELERATION_Z, + CONF_NAME, + ICON_ACCELERATION, + STATE_CLASS_MEASUREMENT, + UNIT_METER_PER_SECOND_SQUARED, +) +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +ACCELERATION_SENSORS = (CONF_ACCELERATION_X, CONF_ACCELERATION_Y, CONF_ACCELERATION_Z) + +accel_schema = cv.maybe_simple_value( + sensor.sensor_schema( + unit_of_measurement=UNIT_METER_PER_SECOND_SQUARED, + icon=ICON_ACCELERATION, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ), + key=CONF_NAME, +) + + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + {cv.Optional(key): accel_schema for key in ACCELERATION_SENSORS} +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACCELERATION_X, hub.set_acceleration_x_sensor) + await sensors(CONF_ACCELERATION_Y, hub.set_acceleration_y_sensor) + await sensors(CONF_ACCELERATION_Z, hub.set_acceleration_z_sensor) diff --git a/esphome/components/lis2dw12/text_sensor.py b/esphome/components/lis2dw12/text_sensor.py new file mode 100644 index 0000000000..b292cc87cc --- /dev/null +++ b/esphome/components/lis2dw12/text_sensor.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_NAME, ICON_SCREEN_ROTATION +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +CONF_ORIENTATION = "orientation" + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + { + cv.Optional(CONF_ORIENTATION): cv.maybe_simple_value( + text_sensor.text_sensor_schema(icon=ICON_SCREEN_ROTATION), + key=CONF_NAME, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_ORIENTATION, hub.set_orientation_text_sensor) diff --git a/esphome/components/lis2dw12_i2c/__init__.py b/esphome/components/lis2dw12_i2c/__init__.py new file mode 100644 index 0000000000..39cbd46a8d --- /dev/null +++ b/esphome/components/lis2dw12_i2c/__init__.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.lis2dw12 import ( + CONFIG_SCHEMA_BASE, + LIS2DW12Component, + to_code_base, + validate_data_rate, +) +import esphome.config_validation as cv +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +AUTO_LOAD = ["lis2dw12"] +DEPENDENCIES = ["i2c"] +DOMAIN = "lis2dw12_i2c" +MULTI_CONF = True + +lis2dw12_i2c_ns = cg.esphome_ns.namespace("lis2dw12_i2c") +LIS2DW12I2CComponent = lis2dw12_i2c_ns.class_( + "LIS2DW12I2CComponent", LIS2DW12Component, i2c.I2CDevice +) + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend( + { + cv.GenerateID(): cv.declare_id(LIS2DW12I2CComponent), + } + ).extend(i2c.i2c_device_schema(0x19)), + validate_data_rate, +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp new file mode 100644 index 0000000000..3956f0a42d --- /dev/null +++ b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp @@ -0,0 +1,13 @@ +#include "lis2dw12_i2c.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12_i2c { + +ESPHOME_LOG_TAG(TAG, "lis2dw12_i2c"); + +void LIS2DW12I2CComponent::dump_config() { + LOG_I2C_DEVICE(this); + lis2dw12::LIS2DW12Component::dump_config(); +} + +} // namespace esphome::lis2dw12_i2c diff --git a/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h new file mode 100644 index 0000000000..aa6069e434 --- /dev/null +++ b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/lis2dw12/lis2dw12.h" + +namespace esphome::lis2dw12_i2c { + +class LIS2DW12I2CComponent : public lis2dw12::LIS2DW12Component, public i2c::I2CDevice { + public: + bool write_byte(uint8_t a_register, uint8_t data) override { return i2c::I2CDevice::write_byte(a_register, data); } + bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { + return i2c::I2CDevice::read_bytes(a_register, data, len); + } + void dump_config() override; +}; + +} // namespace esphome::lis2dw12_i2c diff --git a/esphome/components/lis2dw12_spi/__init__.py b/esphome/components/lis2dw12_spi/__init__.py new file mode 100644 index 0000000000..1b0815d691 --- /dev/null +++ b/esphome/components/lis2dw12_spi/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.lis2dw12 import ( + CONFIG_SCHEMA_BASE, + LIS2DW12Component, + to_code_base, + validate_data_rate, +) +import esphome.config_validation as cv +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +AUTO_LOAD = ["lis2dw12"] +DEPENDENCIES = ["spi"] +DOMAIN = "lis2dw12_spi" +MULTI_CONF = True + +lis2dw12_spi_ns = cg.esphome_ns.namespace("lis2dw12_spi") +LIS2DW12SPIComponent = lis2dw12_spi_ns.class_( + "LIS2DW12SPIComponent", + LIS2DW12Component, + spi.SPIDevice, +) + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend( + { + cv.GenerateID(): cv.declare_id(LIS2DW12SPIComponent), + } + ).extend(spi.spi_device_schema(cs_pin_required=True)), + validate_data_rate, +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await spi.register_spi_device(var, config) diff --git a/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp b/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp new file mode 100644 index 0000000000..0ca013ef6c --- /dev/null +++ b/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp @@ -0,0 +1,34 @@ +#include "lis2dw12_spi.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12_spi { + +ESPHOME_LOG_TAG(TAG, "lis2dw12_spi"); + +void LIS2DW12SPIComponent::setup() { + this->spi_setup(); + lis2dw12::LIS2DW12Component::setup(); +} + +void LIS2DW12SPIComponent::dump_config() { + LOG_SPI_DEVICE(this); + lis2dw12::LIS2DW12Component::dump_config(); +} + +bool LIS2DW12SPIComponent::write_byte(uint8_t a_register, uint8_t data) { + this->enable(); + this->transfer_byte(a_register & 0x3F); + this->transfer_byte(data); + this->disable(); + return true; +} + +bool LIS2DW12SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { + this->enable(); + this->transfer_byte(a_register | 0x80); + this->read_array(data, len); + this->disable(); + return true; +} + +} // namespace esphome::lis2dw12_spi diff --git a/esphome/components/lis2dw12_spi/lis2dw12_spi.h b/esphome/components/lis2dw12_spi/lis2dw12_spi.h new file mode 100644 index 0000000000..0b9b3a2e54 --- /dev/null +++ b/esphome/components/lis2dw12_spi/lis2dw12_spi.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/lis2dw12/lis2dw12.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::lis2dw12_spi { + +class LIS2DW12SPIComponent : public lis2dw12::LIS2DW12Component, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + bool write_byte(uint8_t a_register, uint8_t data) override; + bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; +}; + +} // namespace esphome::lis2dw12_spi diff --git a/tests/components/lis2dw12_i2c/common.yaml b/tests/components/lis2dw12_i2c/common.yaml new file mode 100644 index 0000000000..c0a58a00db --- /dev/null +++ b/tests/components/lis2dw12_i2c/common.yaml @@ -0,0 +1,60 @@ +lis2dw12_i2c: + id: lis2dw12_i2c_accel + i2c_id: i2c_bus + range: 4G + power_mode: high_performance + output_data_rate: 100Hz + filter_bandwidth: odr_div_2 + low_noise: true + update_interval: 10s + calibration: + offset_x: -0.250 + offset_y: -0.400 + offset_z: -0.800 + transform: + mirror_x: false + mirror_y: true + mirror_z: true + swap_xy: false + on_tap: + - then: + - logger.log: "Tapped" + - then: + - logger.log: "Tapped again" + on_double_tap: + - then: + - logger.log: "Double tapped" + on_freefall: + - then: + - logger.log: "Freefall detected" + on_active: + - then: + - logger.log: "Activity detected" + on_orientation: + - then: + - logger.log: "Orientation changed" + +sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_i2c_accel + acceleration_x: Accel X + acceleration_y: Accel Y + acceleration_z: Accel Z + +text_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_i2c_accel + orientation: Orientation + +binary_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_i2c_accel + tap: Single tap + double_tap: + name: Double tap + freefall: + name: Freefall + active: + name: Active + filters: + - delayed_off: 5000ms diff --git a/tests/components/lis2dw12_i2c/test.esp32-idf.yaml b/tests/components/lis2dw12_i2c/test.esp32-idf.yaml new file mode 100644 index 0000000000..e5af154192 --- /dev/null +++ b/tests/components/lis2dw12_i2c/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lis2dw12_i2c: !include common.yaml diff --git a/tests/components/lis2dw12_i2c/test.esp8266-ard.yaml b/tests/components/lis2dw12_i2c/test.esp8266-ard.yaml new file mode 100644 index 0000000000..77fd8e299b --- /dev/null +++ b/tests/components/lis2dw12_i2c/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + lis2dw12_i2c: !include common.yaml diff --git a/tests/components/lis2dw12_i2c/test.rp2040-ard.yaml b/tests/components/lis2dw12_i2c/test.rp2040-ard.yaml new file mode 100644 index 0000000000..286ab79298 --- /dev/null +++ b/tests/components/lis2dw12_i2c/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + lis2dw12_i2c: !include common.yaml diff --git a/tests/components/lis2dw12_spi/common.yaml b/tests/components/lis2dw12_spi/common.yaml new file mode 100644 index 0000000000..88f730a83d --- /dev/null +++ b/tests/components/lis2dw12_spi/common.yaml @@ -0,0 +1,61 @@ +lis2dw12_spi: + id: lis2dw12_spi_accel + spi_id: spi_bus + cs_pin: GPIO5 + range: 4G + power_mode: high_performance + output_data_rate: 100Hz + filter_bandwidth: odr_div_2 + low_noise: true + update_interval: 10s + calibration: + offset_x: -0.250 + offset_y: -0.400 + offset_z: -0.800 + transform: + mirror_x: false + mirror_y: true + mirror_z: true + swap_xy: false + on_tap: + - then: + - logger.log: "Tapped" + - then: + - logger.log: "Tapped again" + on_double_tap: + - then: + - logger.log: "Double tapped" + on_freefall: + - then: + - logger.log: "Freefall detected" + on_active: + - then: + - logger.log: "Activity detected" + on_orientation: + - then: + - logger.log: "Orientation changed" + +sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_spi_accel + acceleration_x: Accel X + acceleration_y: Accel Y + acceleration_z: Accel Z + +text_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_spi_accel + orientation: Orientation + +binary_sensor: + - platform: lis2dw12 + lis2dw12_id: lis2dw12_spi_accel + tap: Single tap + double_tap: + name: Double tap + freefall: + name: Freefall + active: + name: Active + filters: + - delayed_off: 5000ms diff --git a/tests/components/lis2dw12_spi/test.esp32-idf.yaml b/tests/components/lis2dw12_spi/test.esp32-idf.yaml new file mode 100644 index 0000000000..fcb5a6a526 --- /dev/null +++ b/tests/components/lis2dw12_spi/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + lis2dw12_spi: !include common.yaml diff --git a/tests/components/lis2dw12_spi/test.esp8266-ard.yaml b/tests/components/lis2dw12_spi/test.esp8266-ard.yaml new file mode 100644 index 0000000000..d287bfe46a --- /dev/null +++ b/tests/components/lis2dw12_spi/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + lis2dw12_spi: !include common.yaml diff --git a/tests/components/lis2dw12_spi/test.rp2040-ard.yaml b/tests/components/lis2dw12_spi/test.rp2040-ard.yaml new file mode 100644 index 0000000000..6220326702 --- /dev/null +++ b/tests/components/lis2dw12_spi/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + lis2dw12_spi: !include common.yaml From 0f087f425ab56be33ed88bcdd81f43777a0037b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Vallejo?= Date: Mon, 12 Oct 2026 22:52:04 +0200 Subject: [PATCH 204/268] [ld2460] Add HLK-LD2460 radar hub with target sensors (#19002) 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 --- CODEOWNERS | 1 + esphome/components/ld2460/__init__.py | 47 ++++ esphome/components/ld2460/ld2460.cpp | 230 ++++++++++++++++++ esphome/components/ld2460/ld2460.h | 67 +++++ esphome/components/ld2460/sensor.py | 87 +++++++ tests/components/ld2460/common.h | 63 +++++ tests/components/ld2460/common.yaml | 20 ++ .../ld2460/sensor/ld2460_readline.cpp | 183 ++++++++++++++ tests/components/ld2460/test.esp32-idf.yaml | 3 + tests/components/ld2460/test.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_ld2460.yaml | 141 +++++++++++ tests/integration/test_uart_mock_ld2460.py | 160 ++++++++++++ 12 files changed, 1005 insertions(+) create mode 100644 esphome/components/ld2460/__init__.py create mode 100644 esphome/components/ld2460/ld2460.cpp create mode 100644 esphome/components/ld2460/ld2460.h create mode 100644 esphome/components/ld2460/sensor.py create mode 100644 tests/components/ld2460/common.h create mode 100644 tests/components/ld2460/common.yaml create mode 100644 tests/components/ld2460/sensor/ld2460_readline.cpp create mode 100644 tests/components/ld2460/test.esp32-idf.yaml create mode 100644 tests/components/ld2460/test.esp8266-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2460.yaml create mode 100644 tests/integration/test_uart_mock_ld2460.py diff --git a/CODEOWNERS b/CODEOWNERS index 192147f290..2db90be68e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -303,6 +303,7 @@ esphome/components/ld2410/* @regevbr @sebcaps esphome/components/ld2412/* @Rihan9 esphome/components/ld2420/* @descipher esphome/components/ld2450/* @hareeshmu +esphome/components/ld2460/* @jesusvallejo esphome/components/ld24xx/* @kbx81 esphome/components/ld6002b/* @hepter esphome/components/ledc/* @OttoWinter diff --git a/esphome/components/ld2460/__init__.py b/esphome/components/ld2460/__init__.py new file mode 100644 index 0000000000..88c3a173ef --- /dev/null +++ b/esphome/components/ld2460/__init__.py @@ -0,0 +1,47 @@ +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, CONF_ON_DATA +from esphome.types import ConfigType + +AUTO_LOAD = ["ld24xx"] +DEPENDENCIES = ["uart"] +CODEOWNERS = ["@jesusvallejo"] +DOMAIN = "ld2460" +MULTI_CONF = True + +ld2460_ns = cg.esphome_ns.namespace("ld2460") +LD2460Component = ld2460_ns.class_("LD2460Component", cg.Component, uart.UARTDevice) + +CONF_LD2460_ID = "ld2460_id" + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD2460Component), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + require_tx=True, + require_rx=True, + parity="NONE", + stop_bits=1, +) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"), +) + + +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) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp new file mode 100644 index 0000000000..2efd34a1d8 --- /dev/null +++ b/esphome/components/ld2460/ld2460.cpp @@ -0,0 +1,230 @@ +#include "ld2460.h" + +#include "esphome/core/hal.h" + +#include +#include +#include + +namespace esphome::ld2460 { + +ESPHOME_LOG_TAG(TAG, "ld2460"); + +static constexpr uint8_t HEADER_FOOTER_SIZE = 4; +// PROGMEM keeps the patterns out of RAM on the ESP8266; they are copied out for each compare +static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] PROGMEM = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] PROGMEM = {0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] PROGMEM = {0xF4, 0xF3, 0xF2, 0xF1}; +static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] PROGMEM = {0xF8, 0xF7, 0xF6, 0xF5}; + +static constexpr uint8_t CMD_REPORT_DATA = 0x04; +static constexpr uint8_t CMD_QUERY_INSTALLATION_MODE = 0x0A; +static constexpr uint8_t ACK_FRAME_LENGTH = 12; +static constexpr uint8_t MAX_QUERY_ATTEMPTS = 3; +// Header, command, length (12, little endian), payload 0x01, footer +static constexpr uint8_t QUERY_INSTALLATION_MODE_FRAME[] PROGMEM = { + 0xFD, 0xFC, 0xFB, 0xFA, CMD_QUERY_INSTALLATION_MODE, 0x0C, 0x00, 0x01, 0x04, 0x03, 0x02, 0x01, +}; + +static bool matches(const uint8_t *bytes, const uint8_t *pattern) { + uint8_t copy[HEADER_FOOTER_SIZE]; + progmem_memcpy(copy, pattern, sizeof(copy)); + return std::memcmp(bytes, copy, sizeof(copy)) == 0; +} + +// Header (4), command (1), length (2, little endian); the length counts the whole frame +static constexpr uint8_t FRAME_PREFIX_SIZE = 7; +static uint16_t frame_length(const uint8_t *frame) { return encode_uint16(frame[6], frame[5]); } + +static constexpr float RADIANS_TO_DEGREES = 180.0f / std::numbers::pi_v; + +void LD2460Component::setup() { this->send_query_(); } + +void LD2460Component::send_query_() { + uint8_t frame[sizeof(QUERY_INSTALLATION_MODE_FRAME)]; + progmem_memcpy(frame, QUERY_INSTALLATION_MODE_FRAME, sizeof(frame)); + this->write_array(frame, sizeof(frame)); +} + +void LD2460Component::dump_config() { + ESP_LOGCONFIG(TAG, + "LD2460:\n" + " Installation mode: %s", + this->query_attempts_ != 0 ? LOG_STR_LITERAL("unknown") + : this->installation_mode_ == InstallationMode::INSTALLATION_MODE_TOP ? LOG_STR_LITERAL("top") + : LOG_STR_LITERAL("side")); +#ifdef USE_SENSOR + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetCount", this->target_count_sensor_); + for (const auto &t : this->target_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetX", t.x); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetY", t.y); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetDistance", t.distance); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetAngle", t.angle); + } +#endif +} + +void LD2460Component::loop() { + size_t avail = this->available(); + uint8_t buf[MAX_LINE_LENGTH]; + while (avail > 0) { + size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->readline_(buf[i]); + } + } +} + +void LD2460Component::handle_periodic_data_() { + const uint8_t *data = this->buffer_data_; + if (!this->align_to_header_(DATA_FRAME_HEADER) || data[4] != CMD_REPORT_DATA) { + ESP_LOGW(TAG, "Invalid periodic data header"); + return; + } + const uint8_t packet_len = this->buffer_pos_; + if (packet_len < 11 || (packet_len - 11) % 4 != 0) { + ESP_LOGW(TAG, "Periodic packet length %u is not a whole number of targets", packet_len); + return; + } + + if (this->query_attempts_ != 0) { + if (this->query_attempts_ < MAX_QUERY_ATTEMPTS) { + // The module may have booted after us and missed the earlier query + this->query_attempts_++; + this->send_query_(); + } else if (this->query_attempts_ == MAX_QUERY_ATTEMPTS) { + this->query_attempts_++; + ESP_LOGW(TAG, "No installation mode reply after %u queries; assuming side mount", MAX_QUERY_ATTEMPTS); + } + } + +#ifdef USE_SENSOR + uint8_t num_targets = std::min((packet_len - 11) / 4, MAX_TARGETS); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + TargetSensors &t = this->target_sensors_[i]; + if (i >= num_targets) { + t.last_x = INT16_MIN; + t.last_y = INT16_MIN; + SAFE_PUBLISH_SENSOR_UNKNOWN(t.x); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.y); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.distance); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.angle); + continue; + } + const uint8_t *p = &data[7 + i * 4]; + auto raw_x = static_cast(encode_uint16(p[1], p[0])); + auto raw_y = static_cast(encode_uint16(p[3], p[2])); + if (raw_x == t.last_x && raw_y == t.last_y) { + continue; + } + t.last_x = raw_x; + t.last_y = raw_y; + + // Coordinates are reported in 0.1 m steps + float x = raw_x * 0.1f; + float y = raw_y * 0.1f; + SAFE_PUBLISH_SENSOR(t.x, x); + SAFE_PUBLISH_SENSOR(t.y, y); + if (t.distance.has_sensor()) { + SAFE_PUBLISH_SENSOR(t.distance, std::sqrt(x * x + y * y)); + } + if (t.angle.has_sensor()) { + float angle; + if (this->installation_mode_ == InstallationMode::INSTALLATION_MODE_TOP) { + angle = std::atan2(y, x) * RADIANS_TO_DEGREES; + if (angle < 0.0f) { + angle += 360.0f; + } + } else { + angle = std::atan2(x, y) * RADIANS_TO_DEGREES; + } + SAFE_PUBLISH_SENSOR(t.angle, angle); + } + } + SAFE_PUBLISH_SENSOR(this->target_count_sensor_, num_targets); +#endif + + this->data_callback_.call(); +} + +void LD2460Component::readline_(uint8_t readch) { + if (this->buffer_pos_ >= MAX_LINE_LENGTH) { + ESP_LOGW(TAG, "Max frame length exceeded; ignoring"); + this->buffer_pos_ = 0; + } + this->buffer_data_[this->buffer_pos_++] = readch; + + if (this->buffer_pos_ < HEADER_FOOTER_SIZE) { + return; + } + const uint8_t *tail = &this->buffer_data_[this->buffer_pos_ - HEADER_FOOTER_SIZE]; + bool is_data = readch == progmem_read_byte(&DATA_FRAME_FOOTER[3]) && matches(tail, DATA_FRAME_FOOTER); + if (!is_data && (readch != progmem_read_byte(&CMD_FRAME_FOOTER[3]) || !matches(tail, CMD_FRAME_FOOTER))) { + return; + } + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; + ESP_LOGV(TAG, "Frame: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); +#endif + + if (is_data) { + this->handle_periodic_data_(); + } else { + this->handle_ack_(); + } + this->buffer_pos_ = 0; +} + +// Once a footer is matched, shift the buffer to the first offset holding a header whose length +// field ends the frame exactly at the footer. Stray bytes, truncated frames and header bytes inside +// a payload are skipped this way. Returns false when no such offset exists. +bool LD2460Component::align_to_header_(const uint8_t *header) { + for (uint8_t i = 0; i + FRAME_PREFIX_SIZE <= this->buffer_pos_; i++) { + const uint8_t *frame = &this->buffer_data_[i]; + if (matches(frame, header) && frame_length(frame) == this->buffer_pos_ - i) { + if (i != 0) { + this->buffer_pos_ -= i; + std::memmove(this->buffer_data_, frame, this->buffer_pos_); + } + return true; + } + } + return false; +} + +void LD2460Component::handle_ack_() { + const uint8_t *data = this->buffer_data_; + if (!this->align_to_header_(CMD_FRAME_HEADER) || this->buffer_pos_ != ACK_FRAME_LENGTH) { + ESP_LOGW(TAG, "Dropping malformed command frame"); + return; + } + if (data[4] != CMD_QUERY_INSTALLATION_MODE) { + ESP_LOGD(TAG, "Ignoring reply to command 0x%02X", data[4]); + return; + } + const uint8_t mode = data[7]; + if (mode != static_cast(InstallationMode::INSTALLATION_MODE_SIDE) && + mode != static_cast(InstallationMode::INSTALLATION_MODE_TOP)) { + ESP_LOGW(TAG, "Unexpected installation mode %u", mode); + return; + } + this->installation_mode_ = static_cast(mode); + this->query_attempts_ = 0; + ESP_LOGD(TAG, "Installation mode: %s", + mode == static_cast(InstallationMode::INSTALLATION_MODE_TOP) ? LOG_STR_LITERAL("top") + : LOG_STR_LITERAL("side")); +#ifdef USE_SENSOR + // Angles depend on the mode, so republish every target on the next frame + for (auto &t : this->target_sensors_) { + t.last_x = INT16_MIN; + } +#endif +} + +} // namespace esphome::ld2460 diff --git a/esphome/components/ld2460/ld2460.h b/esphome/components/ld2460/ld2460.h new file mode 100644 index 0000000000..aa7679f39a --- /dev/null +++ b/esphome/components/ld2460/ld2460.h @@ -0,0 +1,67 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/components/ld24xx/ld24xx.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::ld2460 { + +static constexpr uint8_t MAX_LINE_LENGTH = 32; // Longest frame is a 5-target data frame of 31 bytes +static constexpr uint8_t MAX_TARGETS = 5; + +enum class InstallationMode : uint8_t { + INSTALLATION_MODE_SIDE = 1, + INSTALLATION_MODE_TOP = 2, +}; + +class LD2460Component : public Component, public uart::UARTDevice { +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(target_count, uint8_t) +#endif + + public: + void setup() override; + void dump_config() override; + void loop() override; + +#ifdef USE_SENSOR + void set_target_x_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].x.set_sensor(s); } + void set_target_y_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].y.set_sensor(s); } + void set_target_distance_sensor(uint8_t target, sensor::Sensor *s) { + this->target_sensors_[target].distance.set_sensor(s); + } + void set_target_angle_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].angle.set_sensor(s); } +#endif + + /// Add a callback that will be called after each successfully processed periodic data frame. + template void add_on_data_callback(F &&callback) { this->data_callback_.add(std::forward(callback)); } + + protected: + void send_query_(); + bool align_to_header_(const uint8_t *header); + void handle_periodic_data_(); + void handle_ack_(); + void readline_(uint8_t readch); + +#ifdef USE_SENSOR + // INT16_MIN marks a slot with no published coordinates, so the next report always publishes + struct TargetSensors { + ld24xx::SensorWithDedup x, y, distance, angle; + int16_t last_x{INT16_MIN}; + int16_t last_y{INT16_MIN}; + }; + std::array target_sensors_{}; +#endif + + LazyCallbackManager data_callback_; + uint8_t buffer_data_[MAX_LINE_LENGTH]; + uint8_t buffer_pos_{0}; + // The angle formula depends on how the module is mounted, so it is read from the module at boot + InstallationMode installation_mode_{InstallationMode::INSTALLATION_MODE_SIDE}; + // Queries sent so far; 0 once the module answered. Data frames resend up to MAX_QUERY_ATTEMPTS. + uint8_t query_attempts_{1}; +}; + +} // namespace esphome::ld2460 diff --git a/esphome/components/ld2460/sensor.py b/esphome/components/ld2460/sensor.py new file mode 100644 index 0000000000..c9d4354e7a --- /dev/null +++ b/esphome/components/ld2460/sensor.py @@ -0,0 +1,87 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT +import esphome.config_validation as cv +from esphome.const import ( + CONF_ANGLE, + CONF_DISTANCE, + CONF_X, + CONF_Y, + DEVICE_CLASS_DISTANCE, + UNIT_DEGREES, + UNIT_METER, +) +from esphome.types import ConfigType + +from . import CONF_LD2460_ID, LD2460Component + +DEPENDENCIES = ["ld2460"] + +ICON_ACCOUNT_GROUP = "mdi:account-group" +ICON_ALPHA_X_BOX_OUTLINE = "mdi:alpha-x-box-outline" +ICON_ALPHA_Y_BOX_OUTLINE = "mdi:alpha-y-box-outline" +ICON_FORMAT_TEXT_ROTATION_ANGLE_UP = "mdi:format-text-rotation-angle-up" +ICON_MAP_MARKER_DISTANCE = "mdi:map-marker-distance" + +MAX_TARGETS = 5 + +_TARGET_SCHEMA = cv.Schema( + { + cv.Optional(CONF_X): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_ALPHA_X_BOX_OUTLINE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=1, + ), + cv.Optional(CONF_Y): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_ALPHA_Y_BOX_OUTLINE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=1, + ), + cv.Optional(CONF_DISTANCE): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_MAP_MARKER_DISTANCE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + ), + cv.Optional(CONF_ANGLE): sensor.sensor_schema( + icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP, + unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=1, + ), + } +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD2460_ID): cv.use_id(LD2460Component), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + icon=ICON_ACCOUNT_GROUP, + ), + **{ + cv.Optional(f"target_{n}"): _TARGET_SCHEMA + for n in range(1, MAX_TARGETS + 1) + }, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2460_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) + + setters = ( + (CONF_X, hub.set_target_x_sensor), + (CONF_Y, hub.set_target_y_sensor), + (CONF_DISTANCE, hub.set_target_distance_sensor), + (CONF_ANGLE, hub.set_target_angle_sensor), + ) + for n in range(MAX_TARGETS): + if (target_conf := config.get(f"target_{n + 1}")) is None: + continue + for key, setter in setters: + if (sens_conf := target_conf.get(key)) is not None: + cg.add(setter(n, await sensor.new_sensor(sens_conf))) diff --git a/tests/components/ld2460/common.h b/tests/components/ld2460/common.h new file mode 100644 index 0000000000..e5b19d5ba2 --- /dev/null +++ b/tests/components/ld2460/common.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include +#include +#include "esphome/components/ld2460/ld2460.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart_component.h" + +namespace esphome::ld2460::testing { + +// Mock UART component to satisfy UARTDevice parent requirement. +class MockUARTComponent : public uart::UARTComponent { + public: + MOCK_METHOD(void, write_array, (const uint8_t *data, size_t len), (override)); + MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override)); + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(size_t, available, (), (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 +}; + +class TestableLD2460 : public LD2460Component { + public: + void feed(const std::vector &data) { + for (uint8_t byte : data) { + this->readline_(byte); + } + } +}; + +struct Point { + int16_t x; // 0.1 m steps + int16_t y; +}; + +// Periodic data frame: header (4) + function (1) + length (2) + 4 bytes per target + footer (4) +inline std::vector make_periodic_frame(const std::vector &targets) { + auto len = static_cast(11 + targets.size() * 4); + std::vector frame = { + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, static_cast(len & 0xFF), static_cast(len >> 8)}; + for (const auto &t : targets) { + auto x = static_cast(t.x); + auto y = static_cast(t.y); + frame.insert(frame.end(), {static_cast(x & 0xFF), static_cast(x >> 8), + static_cast(y & 0xFF), static_cast(y >> 8)}); + } + frame.insert(frame.end(), {0xF8, 0xF7, 0xF6, 0xF5}); + return frame; +} + +// One target at (1.5 m, 2.3 m) +inline std::vector make_periodic_frame() { return make_periodic_frame({{15, 23}}); } + +// ACK for the query installation mode command (0x0A); mode 1 = side, 2 = top +inline std::vector make_ack_frame(uint8_t mode) { + return {0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, mode, 0x04, 0x03, 0x02, 0x01}; +} + +} // namespace esphome::ld2460::testing diff --git a/tests/components/ld2460/common.yaml b/tests/components/ld2460/common.yaml new file mode 100644 index 0000000000..398bcbae6e --- /dev/null +++ b/tests/components/ld2460/common.yaml @@ -0,0 +1,20 @@ +ld2460: + - id: ld2460_radar + on_data: + then: + - logger.log: "LD2460 Radar Data Received" + +sensor: + - platform: ld2460 + ld2460_id: ld2460_radar + target_count: + name: Target Count + target_5: + x: + name: Target-5 X + y: + name: Target-5 Y + angle: + name: Target-5 Angle + distance: + name: Target-5 Distance diff --git a/tests/components/ld2460/sensor/ld2460_readline.cpp b/tests/components/ld2460/sensor/ld2460_readline.cpp new file mode 100644 index 0000000000..43144b09f3 --- /dev/null +++ b/tests/components/ld2460/sensor/ld2460_readline.cpp @@ -0,0 +1,183 @@ +#include + +#include "../common.h" + +namespace esphome::ld2460::testing { + +class LD2460ReadlineTest : public ::testing::Test { + protected: + void SetUp() override { + this->ld2460_.set_uart_parent(&this->mock_uart_); + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, ::testing::_)).Times(::testing::AnyNumber()); + this->ld2460_.set_target_count_sensor(&this->count_); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + this->ld2460_.set_target_x_sensor(i, &this->x_[i]); + this->ld2460_.set_target_y_sensor(i, &this->y_[i]); + this->ld2460_.set_target_distance_sensor(i, &this->distance_[i]); + this->ld2460_.set_target_angle_sensor(i, &this->angle_[i]); + } + } + + MockUARTComponent mock_uart_; + TestableLD2460 ld2460_; + sensor::Sensor count_; + sensor::Sensor x_[MAX_TARGETS]; + sensor::Sensor y_[MAX_TARGETS]; + sensor::Sensor distance_[MAX_TARGETS]; + sensor::Sensor angle_[MAX_TARGETS]; +}; + +TEST_F(LD2460ReadlineTest, BackToBackFrames) { + this->ld2460_.feed(make_periodic_frame({{10, 10}})); + this->ld2460_.feed(make_periodic_frame({{10, 10}, {-20, 30}})); + EXPECT_FLOAT_EQ(this->count_.state, 2); + EXPECT_NEAR(this->x_[1].state, -2.0f, 0.001f); + this->ld2460_.feed(make_periodic_frame({{12, 10}})); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, 1.2f, 0.001f); + EXPECT_TRUE(std::isnan(this->x_[1].state)); +} + +TEST_F(LD2460ReadlineTest, MixedFrameTypes) { + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_ack_frame(1)); + this->ld2460_.feed(make_periodic_frame({{-5, 40}, {5, 40}})); + EXPECT_FLOAT_EQ(this->count_.state, 2); + EXPECT_NEAR(this->y_[1].state, 4.0f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, FooterBytesInsideGarbage) { + this->ld2460_.feed({0x01, 0x02, 0x03, 0x04, 0xF8, 0xF7, 0xF6, 0xF5}); + this->ld2460_.feed({0x10, 0x20, 0x30, 0x40, 0x04, 0x03, 0x02, 0x01}); + EXPECT_TRUE(std::isnan(this->count_.state)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_FLOAT_EQ(this->count_.state, 1); +} + +TEST_F(LD2460ReadlineTest, OverflowThenValidFrame) { + this->ld2460_.feed(std::vector(MAX_LINE_LENGTH + 8, 0x11)); + // The frame right after the overflow lands behind leftover bytes; the header resync skips them + this->ld2460_.feed(make_periodic_frame()); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, 1.5f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, MaxTargetsFrame) { + this->ld2460_.feed(make_periodic_frame({{1, 2}, {3, 4}, {5, 6}, {7, 8}, {9, 10}})); + EXPECT_FLOAT_EQ(this->count_.state, 5); + EXPECT_NEAR(this->x_[4].state, 0.9f, 0.001f); + EXPECT_NEAR(this->y_[4].state, 1.0f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, CoordinateDecoding) { + this->ld2460_.feed(make_periodic_frame({{15, 23}, {-300, -1}})); + EXPECT_NEAR(this->x_[0].state, 1.5f, 0.001f); + EXPECT_NEAR(this->y_[0].state, 2.3f, 0.001f); + EXPECT_NEAR(this->distance_[0].state, 2.746f, 0.001f); + EXPECT_NEAR(this->x_[1].state, -30.0f, 0.001f); + EXPECT_NEAR(this->y_[1].state, -0.1f, 0.001f); + + this->ld2460_.feed(make_periodic_frame({})); + EXPECT_FLOAT_EQ(this->count_.state, 0); + EXPECT_TRUE(std::isnan(this->x_[0].state)); + EXPECT_TRUE(std::isnan(this->distance_[0].state)); + EXPECT_TRUE(std::isnan(this->angle_[1].state)); +} + +TEST_F(LD2460ReadlineTest, AngleByInstallationMode) { + // Side mount (default): atan2(x, y) + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 33.11f, 0.05f); + + // Top mount: atan2(y, x), wrapped to 0..360; the same coordinates are republished + this->ld2460_.feed(make_ack_frame(2)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 56.89f, 0.05f); + this->ld2460_.feed(make_periodic_frame({{15, -23}})); + EXPECT_NEAR(this->angle_[0].state, 303.11f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, AckHandling) { + // An ACK for another command or with a bad length leaves the side mount in place + this->ld2460_.feed({0xFD, 0xFC, 0xFB, 0xFA, 0x0B, 0x0C, 0x00, 0x02, 0x04, 0x03, 0x02, 0x01}); + this->ld2460_.feed({0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0D, 0x00, 0x02, 0x04, 0x03, 0x02, 0x01}); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 33.11f, 0.05f); + + this->ld2460_.feed(make_ack_frame(2)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 56.89f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, QueryResentWhileNoAck) { + // No ACK arrived: the first two data frames resend the 12-byte query (three attempts with the + // one from setup), after which the hub gives up and assumes a side mount + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, 12)).Times(2); + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_periodic_frame({{20, 20}})); + this->ld2460_.feed(make_periodic_frame({{30, 30}})); + this->ld2460_.feed(make_periodic_frame({{40, 40}})); +} + +TEST_F(LD2460ReadlineTest, InvalidModeKeepsQuerying) { + // A mode byte that is neither side nor top is rejected and the next frame asks again + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, 12)).Times(1); + this->ld2460_.feed(make_ack_frame(3)); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_NEAR(this->angle_[0].state, 33.11f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, StrayBytesBeforeFrame) { + // Garbage in front of a frame is skipped once the footer is seen, for data and command frames + this->ld2460_.feed({0x01, 0x02, 0x03}); + this->ld2460_.feed(make_ack_frame(2)); + this->ld2460_.feed({0xAA, 0xBB}); + this->ld2460_.feed(make_periodic_frame()); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->angle_[0].state, 56.89f, 0.05f); +} + +TEST_F(LD2460ReadlineTest, StrayByteThenTruncatedFrame) { + // The truncated frame's header is not followed by a matching length, so the full frame is used + this->ld2460_.feed({0x00, 0xF4, 0xF3, 0xF2, 0xF1, 0x04}); + this->ld2460_.feed(make_periodic_frame({{20, 30}})); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, 2.0f, 0.001f); + EXPECT_NEAR(this->y_[0].state, 3.0f, 0.001f); +} + +TEST_F(LD2460ReadlineTest, HeaderBytesInsidePayload) { + // The target bytes F4 F3 F2 F1 repeat the data header; the frame still parses at offset 0 + this->ld2460_.feed(make_periodic_frame({{static_cast(0xF3F4), static_cast(0xF1F2)}})); + EXPECT_FLOAT_EQ(this->count_.state, 1); + EXPECT_NEAR(this->x_[0].state, -308.4f, 0.01f); + EXPECT_NEAR(this->y_[0].state, -359.8f, 0.01f); +} + +TEST_F(LD2460ReadlineTest, PartialTargetRejected) { + // 13 bytes of payload is not a whole number of 4-byte targets + std::vector frame = {0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x0D, 0x00, 0x0F, 0x00, 0xF8, 0xF7, 0xF6, 0xF5}; + this->ld2460_.feed(frame); + EXPECT_TRUE(std::isnan(this->count_.state)); +} + +TEST_F(LD2460ReadlineTest, NoQueryAfterAck) { + EXPECT_CALL(this->mock_uart_, write_array(::testing::_, ::testing::_)).Times(0); + this->ld2460_.feed(make_ack_frame(1)); + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_periodic_frame({{20, 20}})); +} + +TEST_F(LD2460ReadlineTest, DataCallbackOncePerValidFrame) { + int calls = 0; + this->ld2460_.add_on_data_callback([&calls]() { calls++; }); + this->ld2460_.feed(make_periodic_frame()); + this->ld2460_.feed(make_periodic_frame({{20, 20}})); + EXPECT_EQ(calls, 2); + // A frame with the data footer but a wrong header is rejected without a callback + this->ld2460_.feed({0x00, 0x00, 0x00, 0x00, 0x04, 0x0B, 0x00, 0xF8, 0xF7, 0xF6, 0xF5}); + this->ld2460_.feed(make_ack_frame(2)); + EXPECT_EQ(calls, 2); +} + +} // namespace esphome::ld2460::testing diff --git a/tests/components/ld2460/test.esp32-idf.yaml b/tests/components/ld2460/test.esp32-idf.yaml new file mode 100644 index 0000000000..e4efcf68fa --- /dev/null +++ b/tests/components/ld2460/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + ld2460: !include common.yaml diff --git a/tests/components/ld2460/test.esp8266-ard.yaml b/tests/components/ld2460/test.esp8266-ard.yaml new file mode 100644 index 0000000000..d66d260423 --- /dev/null +++ b/tests/components/ld2460/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + ld2460: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_ld2460.yaml b/tests/integration/fixtures/uart_mock_ld2460.yaml new file mode 100644 index 0000000000..7c67392439 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2460.yaml @@ -0,0 +1,141 @@ +esphome: + name: uart-mock-ld2460-test + +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 ld2460'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: 115200 + auto_start: false + responses: + # The hub queries the installation mode at boot (HLK-LD2460 serial protocol v1.0, table 11): + # FD FC FB FA 0A 0C 00 01 04 03 02 01 + # Answer with the side-mount receipt from table 12 (mode byte 01). + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, 0x01, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, 0x01, 0x04, 0x03, 0x02, 0x01] + + injections: + # Phase 1 (t=100ms): the datasheet's own report example (table 2), one target at (1.5 m, 2.3 m) + # F4 F3 F2 F1 = header, 04 = function, 0F 00 = length 15 (little endian), + # 0F 00 = X 15 (0.1 m steps), 17 00 = Y 23, F8 F7 F6 F5 = footer + # Side mount: distance = sqrt(1.5² + 2.3²) = 2.746 m, angle = atan2(1.5, 2.3) = 33.1° + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x0F, 0x00, + 0x0F, 0x00, 0x17, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Phase 2 (t=300ms): garbage bytes accumulate in the parser buffer + - delay: 200ms + inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22] + + # Phase 3 (t=400ms): a report header with partial data and no footer + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x13, 0x00, 0x01, 0x02] + + # Phase 4 (t=600ms): overflow the 32-byte frame buffer. Phases 2 and 3 left 16 bytes + # in it; 48 more make 64, so the parser fills the buffer twice. The 33rd byte logs + # "Max frame length exceeded" and restarts the buffer, the 64th leaves it full, and the + # first byte of the next frame restarts it again, so phase 5 parses from a clean buffer. + - delay: 200ms + inject_rx: + [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ] + + # Phase 5 (t=700ms): recovery, two targets after the overflow + # Target 1 at (-2.0 m, 3.0 m): X = -20 = 0xFFEC, Y = 30 = 0x001E + # distance = 3.606 m, side-mount angle = atan2(-2.0, 3.0) = -33.7° + # Target 2 at (0.5 m, 1.0 m): X = 5, Y = 10 + # distance = 1.118 m, angle = atan2(0.5, 1.0) = 26.6° + # length = 11 + 2 * 4 = 19 = 0x13 + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x13, 0x00, + 0xEC, 0xFF, 0x1E, 0x00, + 0x05, 0x00, 0x0A, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Phase 6 (t=900ms): the datasheet's top-mount receipt (table 12 example, + # FD FC FB FA 0A 0C 00 02 04 03 02 01) switches the angle formula, then the + # same single target as phase 1 reports again + # Top mount: angle = atan2(2.3, 1.5) = 56.9° + - delay: 200ms + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x0A, 0x0C, 0x00, 0x02, 0x04, 0x03, 0x02, 0x01] + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x04, 0x0F, 0x00, + 0x0F, 0x00, 0x17, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + +ld2460: + id: ld2460_dev + uart_id: mock_uart + +sensor: + - platform: ld2460 + ld2460_id: ld2460_dev + target_count: + name: "Target Count" + filters: &sensor_filters + - timeout: + timeout: 50ms + value: last + - throttle_with_priority: 50ms + target_1: + x: + name: "Target 1 X" + filters: *sensor_filters + y: + name: "Target 1 Y" + filters: *sensor_filters + distance: + name: "Target 1 Distance" + filters: *sensor_filters + angle: + name: "Target 1 Angle" + filters: *sensor_filters + target_2: + x: + name: "Target 2 X" + filters: *sensor_filters + y: + name: "Target 2 Y" + filters: *sensor_filters + distance: + name: "Target 2 Distance" + filters: *sensor_filters + angle: + name: "Target 2 Angle" + filters: *sensor_filters + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/test_uart_mock_ld2460.py b/tests/integration/test_uart_mock_ld2460.py new file mode 100644 index 0000000000..4374ee92d7 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2460.py @@ -0,0 +1,160 @@ +"""Integration test for the LD2460 component with a mock UART. + +The frames come from the HLK-LD2460 serial protocol v1.0: the report example in +table 2 (one target at 1.5 m, 2.3 m), the installation-mode query in table 11 and +the receipts in table 12. + +test_uart_mock_ld2460: + 1. The hub queries the installation mode at boot (TX logged by the mock) + 2. The datasheet report example decodes to x, y, distance and angle (side mount) + 3. Garbage, a truncated frame and a buffer overflow do not stop the parser + 4. A two-target frame after the overflow decodes, including a negative X + 5. A top-mount receipt switches the angle formula for the next frame +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_ld2460( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LD2460 frame parsing with the datasheet frames, garbage and overflow.""" + 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() + overflow_seen = loop.create_future() + tx_log_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "Max frame length exceeded" in line and not overflow_seen.done(): + overflow_seen.set_result(True) + if "uart_mock" in line and "TX " in line: + tx_log_lines.append(line) + + collector = SensorStateCollector( + sensor_names=[ + "target_count", + "target_1_x", + "target_1_y", + "target_1_distance", + "target_1_angle", + "target_2_x", + "target_2_y", + "target_2_distance", + "target_2_angle", + ], + ) + + # Phase 5: two targets after the overflow + recovery_received = collector.add_waiter( + lambda: ( + pytest.approx(2.0) in collector.sensor_states["target_count"] + and pytest.approx(-2.0, abs=0.01) in collector.sensor_states["target_1_x"] + and pytest.approx(3.0, abs=0.01) in collector.sensor_states["target_1_y"] + and pytest.approx(0.5, abs=0.01) in collector.sensor_states["target_2_x"] + and pytest.approx(1.0, abs=0.01) in collector.sensor_states["target_2_y"] + and pytest.approx(1.118, abs=0.01) + in collector.sensor_states["target_2_distance"] + ) + ) + # Phase 6: the same target as phase 1, measured as a top mount + top_mount_received = collector.add_waiter( + lambda: ( + pytest.approx(56.9, abs=0.1) in collector.sensor_states["target_1_angle"] + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + 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") + + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + # Phase 1: the datasheet example, one target at (1.5, 2.3) + phase1 = collector.add_waiter( + lambda: ( + pytest.approx(1.0) in collector.sensor_states["target_count"] + and pytest.approx(33.1, abs=0.1) + in collector.sensor_states["target_1_angle"] + ) + ) + try: + await asyncio.wait_for(phase1, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + assert collector.sensor_states["target_1_x"][0] == pytest.approx(1.5, abs=0.01) + assert collector.sensor_states["target_1_y"][0] == pytest.approx(2.3, abs=0.01) + assert collector.sensor_states["target_1_distance"][0] == pytest.approx( + 2.746, abs=0.01 + ) + assert collector.sensor_states["target_1_angle"][0] == pytest.approx( + 33.1, abs=0.1 + ) + assert collector.sensor_states["target_count"][0] == pytest.approx(1.0) + + # Phase 5: parsing survives garbage, a truncated frame and an overflow + try: + await asyncio.wait_for(recovery_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the recovery frame. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + assert overflow_seen.done(), "Expected 'Max frame length exceeded' in the log" + assert ( + pytest.approx(3.606, abs=0.01) + in collector.sensor_states["target_1_distance"] + ) + assert ( + pytest.approx(-33.7, abs=0.1) in collector.sensor_states["target_1_angle"] + ) + assert pytest.approx(26.6, abs=0.1) in collector.sensor_states["target_2_angle"] + + # Phase 6: the top-mount receipt changes the angle for the same coordinates + try: + await asyncio.wait_for(top_mount_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the top-mount angle. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + + # The hub sent the installation-mode query from the datasheet (table 11) + tx_data = " ".join(tx_log_lines) + assert "FD:FC:FB:FA:0A:0C:00:01:04:03:02:01" in tx_data, ( + f"Expected the installation-mode query in the TX log, got: {tx_data}" + ) From ffa849753a7f141904cf3b537e9ba3cd81c1ef46 Mon Sep 17 00:00:00 2001 From: Stephen Cox <69124219+linkedupbits@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:00:05 +1300 Subject: [PATCH 205/268] [ds3231] Add DS3231 real-time clock hub with temperature sensor (#18883) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ds3231/__init__.py | 35 ++++++++ esphome/components/ds3231/ds3231.cpp | 82 +++++++++++++++++++ esphome/components/ds3231/ds3231.h | 32 ++++++++ esphome/components/ds3231/sensor/__init__.py | 41 ++++++++++ .../ds3231/sensor/ds3231_temperature.cpp | 23 ++++++ .../ds3231/sensor/ds3231_temperature.h | 17 ++++ tests/components/ds3231/common.yaml | 15 ++++ tests/components/ds3231/test.esp32-idf.yaml | 3 + tests/components/ds3231/test.esp8266-ard.yaml | 3 + 10 files changed, 252 insertions(+) create mode 100644 esphome/components/ds3231/__init__.py create mode 100644 esphome/components/ds3231/ds3231.cpp create mode 100644 esphome/components/ds3231/ds3231.h create mode 100644 esphome/components/ds3231/sensor/__init__.py create mode 100644 esphome/components/ds3231/sensor/ds3231_temperature.cpp create mode 100644 esphome/components/ds3231/sensor/ds3231_temperature.h create mode 100644 tests/components/ds3231/common.yaml create mode 100644 tests/components/ds3231/test.esp32-idf.yaml create mode 100644 tests/components/ds3231/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2db90be68e..27aac4fb68 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -156,6 +156,7 @@ esphome/components/ds1307/* @badbadc0ffee esphome/components/ds1603l/* @JakeLC15 esphome/components/ds2484/* @mrk-its esphome/components/ds248x/* @tomwellnitz +esphome/components/ds3231/* @linkedupbits esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov esphome/components/ee895/* @Stock-M diff --git a/esphome/components/ds3231/__init__.py b/esphome/components/ds3231/__init__.py new file mode 100644 index 0000000000..262c951e80 --- /dev/null +++ b/esphome/components/ds3231/__init__.py @@ -0,0 +1,35 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@linkedupbits"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True +DOMAIN = "ds3231" + +CONF_DS3231_ID = "ds3231_id" + +ds3231_ns = cg.esphome_ns.namespace("ds3231") +DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice) + +CONFIG_SCHEMA = ( + cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)}) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x68)) +) + + +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) + + +automation.register_apply_action( + "ds3231.force_temperature_conversion", + automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}), + automation.ApplyCall("force_temperature_conversion()"), +) diff --git a/esphome/components/ds3231/ds3231.cpp b/esphome/components/ds3231/ds3231.cpp new file mode 100644 index 0000000000..4f7396fcbd --- /dev/null +++ b/esphome/components/ds3231/ds3231.cpp @@ -0,0 +1,82 @@ +#include "ds3231.h" +#include "esphome/core/log.h" + +// Datasheet: +// - https://www.analog.com/media/en/technical-documentation/data-sheets/DS3231.pdf + +namespace esphome::ds3231 { + +ESPHOME_LOG_TAG(TAG, "ds3231"); + +static constexpr uint8_t DS3231_REG_CONTROL = 0x0E; +static constexpr uint8_t DS3231_REG_TEMPERATURE = 0x11; + +void DS3231Component::setup() { + if (!this->read_control_status_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + this->control_reg_ &= ~CONTROL_EOSC; // keep the oscillator running on battery + // INT/SQW is the (unused) interrupt line rather than a square-wave output. + this->control_reg_ |= CONTROL_INTCN; + if (!this->write_control_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } +} + +void DS3231Component::dump_config() { + ESP_LOGCONFIG(TAG, "DS3231:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } +} + +bool DS3231Component::read_temperature(float &out) { + uint8_t raw[2]; + if (this->is_failed() || !this->read_bytes(DS3231_REG_TEMPERATURE, raw, sizeof(raw))) { + return false; + } + // 10-bit two's complement, 0.25 degC per LSB. + int16_t value = static_cast((raw[0] << 8) | raw[1]) >> 6; + out = value * 0.25f; + return true; +} + +void DS3231Component::force_temperature_conversion() { + // Just kick off the conversion - the chip clears CONV and updates the temperature + // register on its own within ~200 ms, and the next temperature poll picks it up. + // Blocking here to wait for it would stall the main loop. + if (this->is_failed() || !this->read_control_status_()) + return; + if ((this->status_reg_ & STATUS_BSY) != 0) { + ESP_LOGW(TAG, "A temperature conversion is already in progress."); + return; + } + this->control_reg_ |= CONTROL_CONV; + this->write_control_(); +} + +bool DS3231Component::read_control_status_() { + uint8_t raw[2]; + if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) { + ESP_LOGW(TAG, "Can't read control/status registers."); + return false; + } + this->control_reg_ = raw[0]; + this->status_reg_ = raw[1]; + return true; +} + +bool DS3231Component::write_control_() { + if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) { + ESP_LOGW(TAG, "Can't write control register."); + return false; + } + return true; +} + +} // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/ds3231.h b/esphome/components/ds3231/ds3231.h new file mode 100644 index 0000000000..b8a81a2e49 --- /dev/null +++ b/esphome/components/ds3231/ds3231.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::ds3231 { + +/// Hub for a single DS3231 real-time clock. Owns all register access; the platforms talk to the +/// chip through this class. +class DS3231Component final : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + bool read_temperature(float &out); + void force_temperature_conversion(); + + protected: + static constexpr uint8_t CONTROL_INTCN = 0x04; + static constexpr uint8_t CONTROL_CONV = 0x20; + static constexpr uint8_t CONTROL_EOSC = 0x80; + + static constexpr uint8_t STATUS_BSY = 0x04; + + bool read_control_status_(); + bool write_control_(); + + uint8_t control_reg_{0}; + uint8_t status_reg_{0}; +}; + +} // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/sensor/__init__.py b/esphome/components/ds3231/sensor/__init__.py new file mode 100644 index 0000000000..a92fc1f48c --- /dev/null +++ b/esphome/components/ds3231/sensor/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.types import ConfigType + +from .. import CONF_DS3231_ID, DS3231Component, ds3231_ns + +DEPENDENCIES = ["ds3231"] + +DS3231TemperatureSensor = ds3231_ns.class_( + "DS3231TemperatureSensor", + sensor.Sensor, + cg.PollingComponent, + cg.Parented.template(DS3231Component), +) + + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + DS3231TemperatureSensor, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_THERMOMETER, + ) + .extend({cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component)}) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_DS3231_ID]) diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.cpp b/esphome/components/ds3231/sensor/ds3231_temperature.cpp new file mode 100644 index 0000000000..8d7e1156be --- /dev/null +++ b/esphome/components/ds3231/sensor/ds3231_temperature.cpp @@ -0,0 +1,23 @@ +#include "ds3231_temperature.h" +#include "esphome/core/log.h" + +namespace esphome::ds3231 { + +ESPHOME_LOG_TAG(TAG, "ds3231.sensor"); + +void DS3231TemperatureSensor::update() { + float temperature; + if (!this->parent_->read_temperature(temperature)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + this->publish_state(temperature); +} + +void DS3231TemperatureSensor::dump_config() { + LOG_SENSOR("", "DS3231 Temperature", this); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.h b/esphome/components/ds3231/sensor/ds3231_temperature.h new file mode 100644 index 0000000000..aa31c42438 --- /dev/null +++ b/esphome/components/ds3231/sensor/ds3231_temperature.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/sensor/sensor.h" + +#include "../ds3231.h" + +namespace esphome::ds3231 { + +class DS3231TemperatureSensor final : public sensor::Sensor, public PollingComponent, public Parented { + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::ds3231 diff --git a/tests/components/ds3231/common.yaml b/tests/components/ds3231/common.yaml new file mode 100644 index 0000000000..9f087cb941 --- /dev/null +++ b/tests/components/ds3231/common.yaml @@ -0,0 +1,15 @@ +ds3231: + - id: ds3231_component + i2c_id: i2c_bus + address: 0x68 + +sensor: + - platform: ds3231 + ds3231_id: ds3231_component + name: DS3231 Temperature + update_interval: never + +interval: + - interval: 60s + then: + - ds3231.force_temperature_conversion: ds3231_component diff --git a/tests/components/ds3231/test.esp32-idf.yaml b/tests/components/ds3231/test.esp32-idf.yaml new file mode 100644 index 0000000000..fb7ac2fba5 --- /dev/null +++ b/tests/components/ds3231/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + ds3231: !include common.yaml diff --git a/tests/components/ds3231/test.esp8266-ard.yaml b/tests/components/ds3231/test.esp8266-ard.yaml new file mode 100644 index 0000000000..ca42d95738 --- /dev/null +++ b/tests/components/ds3231/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ds3231: !include common.yaml From 0b6856c0524bdb009e12bd543fd914853b331281 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 17:17:58 -0400 Subject: [PATCH 206/268] [udp] Add IPv6 unicast and multicast (#20590) Co-authored-by: J. Nick Koston Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/socket/socket.cpp | 6 +- esphome/components/socket/socket.h | 5 + esphome/components/udp/__init__.py | 97 +++++++- esphome/components/udp/udp_component.cpp | 228 +++++++++++++----- esphome/components/udp/udp_component.h | 52 +++- .../udp/test_udp_listen_address.py | 135 +++++++++++ tests/components/udp/common-ipv6.yaml | 20 ++ tests/components/udp/common.yaml | 56 +++-- tests/components/udp/test-ipv6.esp32-idf.yaml | 4 + tests/components/udp/test.host.yaml | 5 + tests/components/udp/test.nrf52-adafruit.yaml | 5 + .../fixtures/udp_ipv6_unicast.yaml | 24 ++ tests/integration/test_udp_ipv6.py | 63 +++++ 13 files changed, 592 insertions(+), 108 deletions(-) create mode 100644 tests/component_tests/udp/test_udp_listen_address.py create mode 100644 tests/components/udp/common-ipv6.yaml create mode 100644 tests/components/udp/test-ipv6.esp32-idf.yaml create mode 100644 tests/components/udp/test.nrf52-adafruit.yaml create mode 100644 tests/integration/fixtures/udp_ipv6_unicast.yaml create mode 100644 tests/integration/test_udp_ipv6.py diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index dd9387785f..630d68b562 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -263,8 +263,7 @@ bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_ } return true; } - // The raw lwIP implementation has no IPv6 membership support -#if USE_NETWORK_IPV6 && (defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)) +#ifdef USE_SOCKET_IPV6 struct ipv6_mreq imreq6 {}; #ifdef USE_SOCKET_IMPL_BSD_SOCKETS #ifdef USE_ZEPHYR @@ -315,7 +314,7 @@ bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_ #else errno = EINVAL; return false; -#endif // USE_NETWORK_IPV6 && fd based sockets +#endif // USE_SOCKET_IPV6 } #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) @@ -335,6 +334,7 @@ bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) { return true; }); if (ifindex == 0) { + errno = EADDRNOTAVAIL; return false; } } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 7310e32afb..e8f9208774 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -56,6 +56,11 @@ inline constexpr int IP_DOMAIN = AF_INET6; inline constexpr int IP_DOMAIN = AF_INET; #endif +// IPv6 sockets with multicast membership; the raw lwIP implementation has no IPv6 membership support. +#if USE_NETWORK_IPV6 && (defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)) +#define USE_SOCKET_IPV6 +#endif + #ifdef USE_LWIP_FAST_SELECT /// Shared ready() helper using cached lwip_sock pointer for direct rcvevent read. /// cached_sock == nullptr means the socket is not monitored (monitor_loop was false, fd diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 81149f056b..5957870e7f 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,9 +1,11 @@ +from ipaddress import IPv4Address, IPv6Address from typing import Any from esphome import automation from esphome.automation import Trigger import esphome.codegen as cg from esphome.components.const import CONF_ON_RECEIVE +from esphome.components.network import DOMAIN as NETWORK_DOMAIN from esphome.components.packet_transport import ( CONF_BINARY_SENSORS, CONF_ENCRYPTION, @@ -13,9 +15,16 @@ from esphome.components.packet_transport import ( CONF_SENSORS, ) import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID +from esphome.const import ( + CONF_DATA, + CONF_ENABLE_IPV6, + CONF_ID, + CONF_PORT, + CONF_TRIGGER_ID, +) from esphome.core import ID from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -63,13 +72,80 @@ RELOCATED = { } -def _consume_udp_sockets(config: ConfigType) -> ConfigType: - """Register socket needs for UDP component.""" +def _no_scope_id(addr: IPv4Address | IPv6Address) -> IPv4Address | IPv6Address: + """Python accepts a scope id, which neither inet_pton nor lwIP's inet6_aton takes.""" + if isinstance(addr, IPv6Address) and addr.scope_id is not None: + raise cv.Invalid( + f"{addr} carries a scope id, which the device cannot parse; " + "give the address without the '%' part" + ) + return addr + + +def _address(value: Any) -> IPv4Address | IPv6Address: + addr = _no_scope_id(cv.ipaddress(value)) + # A link-local destination needs a scope id, which the device cannot pick on every platform + if isinstance(addr, IPv6Address) and addr.is_link_local: + raise cv.Invalid( + f"{addr} is link-local; use a multicast, unique local or global address" + ) + return addr + + +def _listen_address(value: Any) -> IPv4Address | IPv6Address: + """Accept an IPv4 multicast/broadcast or an IPv6 multicast address, with a family-specific error.""" + if ":" not in str(value): + return cv.ipv4address_multi_broadcast(value) + addr = _no_scope_id(cv.ipv6address(value)) + if not addr.is_multicast: + raise cv.Invalid(f"{value} is not an IPv6 multicast address") + return addr + + +def _final_validate(config: ConfigType) -> None: from esphome.components import socket - # UDP uses up to 2 sockets: 1 broadcast + 1 listen - # Whether each is used depends on code generation, so register worst case - socket.consume_sockets(2, "udp", socket.SocketType.UDP)(config) + full_config = fv.full_config.get() + ipv6_enabled = full_config.get(NETWORK_DOMAIN, {}).get(CONF_ENABLE_IPV6, False) + raw_lwip = ( + full_config.get(socket.DOMAIN, {}).get(socket.CONF_IMPLEMENTATION) + == socket.IMPLEMENTATION_LWIP_TCP + ) + for key, addresses in ( + (CONF_ADDRESSES, config[CONF_ADDRESSES]), + (CONF_LISTEN_ADDRESS, [config[CONF_LISTEN_ADDRESS]]), + ): + for addr in addresses: + if addr.version != 6: + continue + if raw_lwip: + raise cv.Invalid( + f"IPv6 address '{addr}' is not supported on this platform", [key] + ) + if not ipv6_enabled: + raise cv.Invalid( + f"IPv6 address '{addr}' requires 'enable_ipv6: true' in the 'network' component", + [key], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def _ipv6_addresses(config: ConfigType) -> list[IPv6Address]: + return [addr for addr in config[CONF_ADDRESSES] if addr.version == 6] + + +def _has_ipv4_address(config: ConfigType) -> bool: + return any(addr.version == 4 for addr in config[CONF_ADDRESSES]) + + +def _consume_udp_sockets(config: ConfigType) -> ConfigType: + """Reserve the listen socket plus one send socket per address family in use.""" + from esphome.components import socket + + count = 1 + _has_ipv4_address(config) + bool(_ipv6_addresses(config)) + socket.consume_sockets(count, "udp", socket.SocketType.UDP)(config) return config @@ -88,9 +164,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional( CONF_LISTEN_ADDRESS, default="255.255.255.255" - ): cv.ipv4address_multi_broadcast, + ): _listen_address, cv.Optional(CONF_ADDRESSES, default=["255.255.255.255"]): cv.ensure_list( - cv.ipv4address, + _address ), cv.Optional(CONF_ON_RECEIVE): automation.validate_automation( { @@ -125,6 +201,11 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_broadcast_port(conf_port[CONF_BROADCAST_PORT])) if (listen_address := str(config[CONF_LISTEN_ADDRESS])) != "255.255.255.255": cg.add(var.set_listen_address(listen_address)) + if ipv6_addresses := _ipv6_addresses(config): + cg.add_define("USE_UDP_IPV6_SEND") + cg.add(var.set_ipv6_send(any(addr.is_multicast for addr in ipv6_addresses))) + if not _has_ipv4_address(config): + cg.add(var.set_ipv4_send(False)) # Shared flash table ended by nullptr, so the component stores only a pointer. if addresses := [str(addr) for addr in config[CONF_ADDRESSES]]: cg.add( diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index e6046ebc36..e143f93a4b 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -8,44 +8,95 @@ namespace esphome::udp { ESPHOME_LOG_TAG(TAG, "udp"); -static constexpr uint32_t JOIN_POLL_ID = 0; -static constexpr uint32_t JOIN_POLL_INTERVAL_MS = 1000; +static constexpr uint32_t NETWORK_POLL_ID = 0; +static constexpr uint32_t NETWORK_POLL_INTERVAL_MS = 1000; void UDPComponent::setup() { + size_t address_count = 0; + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) + address_count++; + this->sockaddrs_.init(address_count); for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { const char *address = *it; - struct sockaddr saddr {}; - if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) { + SockaddrEntry entry{}; + if (socket::set_sockaddr(&entry.sa, sizeof(entry), address, this->broadcast_port_) == 0) { ESP_LOGW(TAG, "Invalid address %s", address); // A dropped address silently receives nothing; surface the misconfiguration this->status_set_warning(LOG_STR("invalid address")); continue; } - this->sockaddrs_.push_back(saddr); + this->sockaddrs_.push_back(entry); } // set up broadcast socket if (this->should_broadcast_) { - this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); - if (this->broadcast_socket_ == nullptr) { - this->status_set_error(LOG_STR("Could not create socket")); - this->mark_failed(); - return; +#ifdef USE_UDP_IPV6_SEND + if (this->ipv4_send_) +#endif + { + this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); + if (this->broadcast_socket_ == nullptr) { + this->status_set_error(LOG_STR("Could not create socket")); + this->mark_failed(); + return; + } + int enable = 1; + auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); + if (err != 0) { + this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); + // we can still continue + } + err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); + if (err != 0) { + this->status_set_warning(LOG_STR("Socket unable to set broadcast")); + } } - int enable = 1; - auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); - if (err != 0) { - this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); - // we can still continue - } - err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); - if (err != 0) { - this->status_set_warning(LOG_STR("Socket unable to set broadcast")); +#ifdef USE_UDP_IPV6_SEND + if (this->ipv6_send_) { + this->send_socket_v6_ = socket::socket_udp_send(AF_INET6, IPPROTO_IP); + if (this->send_socket_v6_ == nullptr) { + this->status_set_error(LOG_STR("Could not create IPv6 socket")); + this->mark_failed(); + return; + } + // Unicast needs no egress interface; multicast does + if (this->ipv6_multicast_) + this->poll_pending_ |= POLL_MCAST_IF; } +#endif } // create listening socket if we either want to subscribe to providers, or need to listen // for ping key broadcasts. if (this->should_listen_) { - this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET, IPPROTO_IP); + struct sockaddr_storage server {}; + socklen_t server_len; + if (this->listen_address_ != nullptr) { + server_len = + socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), this->listen_address_, this->listen_port_); + // A multicast join needs a socket of the group's address family + this->listen_socket_ = socket::socket_udp_loop_monitored(server.ss_family, IPPROTO_IP); + this->poll_pending_ |= POLL_JOIN; +#ifdef USE_SOCKET_IPV6 + // An IPv6 group binds after the join, which supplies the scope id link-local groups need + if (server.ss_family == AF_INET6) + this->poll_pending_ |= POLL_BIND; +#endif + } else { +#ifdef USE_SOCKET_IPV6 + server_len = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->listen_port_); + this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET6, IPPROTO_IP); + // Clear IPV6_V6ONLY so IPv4 datagrams also arrive, as mapped addresses + int v6only = 0; + if (this->listen_socket_ != nullptr && + this->listen_socket_->setsockopt(IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only)) != 0) { + ESP_LOGW(TAG, "Failed to clear IPV6_V6ONLY: errno %d", errno); + this->status_set_warning(LOG_STR("IPv4 datagrams may not be received")); + } +#else + // Must stay IPv4: raw lwIP can have USE_NETWORK_IPV6 without IPv6 sockets + server_len = socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), "0.0.0.0", this->listen_port_); + this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET, IPPROTO_IP); +#endif + } if (this->listen_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -64,51 +115,93 @@ void UDPComponent::setup() { this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); // we can still continue } - struct sockaddr_in server {}; - server.sin_family = AF_INET; - server.sin_addr.s_addr = ESPHOME_INADDR_ANY; - server.sin_port = htons(this->listen_port_); - - if (this->listen_address_ != nullptr) { - inet_aton(this->listen_address_, &server.sin_addr); - // Joined once the network is up; RP2040 has no interface to join before then - this->set_interval(JOIN_POLL_ID, JOIN_POLL_INTERVAL_MS, [this]() { - if (network::is_connected() && this->join_multicast_()) - this->cancel_interval(JOIN_POLL_ID); - }); - } - - err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server)); - if (err != 0) { - ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); - this->status_set_error(LOG_STR("Unable to bind socket")); - this->mark_failed(); - return; + if ((this->poll_pending_ & POLL_BIND) == 0) { + err = this->listen_socket_->bind((struct sockaddr *) &server, server_len); + if (err != 0) { + ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); + this->status_set_error(LOG_STR("Unable to bind socket")); + this->mark_failed(); + return; + } } } + if (this->poll_pending_ != 0) { + // Joins and the IPv6 egress interface need the network up; RP2040 has no interface before then + this->set_interval(NETWORK_POLL_ID, NETWORK_POLL_INTERVAL_MS, [this]() { + if (network::is_connected() && this->network_poll_()) + this->cancel_interval(NETWORK_POLL_ID); + }); + } } -bool UDPComponent::join_multicast_() { - if (socket::join_multicast_group(this->listen_socket_.get(), this->listen_address_)) { - ESP_LOGD(TAG, "Joined multicast %s", this->listen_address_); - if (this->join_warning_) { - this->join_warning_ = false; - this->status_clear_warning(); - } - return true; - } - // Log the first failure only; the poll keeps retrying every second - if (!this->join_failed_) { - this->join_failed_ = true; - ESP_LOGW(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); - // One warning bit: only a warning raised here is cleared on success, one from setup() stays - if (!this->status_has_warning()) { - this->status_set_warning(LOG_STR("Failed to set IP_ADD_MEMBERSHIP")); - this->join_warning_ = true; +bool UDPComponent::network_poll_() { + bool ok = true; + if (this->poll_pending_ & POLL_JOIN) { +#ifdef USE_SOCKET_IPV6 + uint32_t *if_index = &this->mcast_ifindex_; +#else + uint32_t *if_index = nullptr; +#endif + if (socket::join_multicast_group(this->listen_socket_.get(), this->listen_address_, if_index)) { + ESP_LOGD(TAG, "Joined multicast %s", this->listen_address_); + this->poll_pending_ &= ~POLL_JOIN; + this->poll_failed_ &= ~POLL_JOIN; + } else { + this->set_poll_warning_(POLL_JOIN, LOG_STR("Failed to join multicast group")); + ok = false; } } - return false; +#ifdef USE_SOCKET_IPV6 + if (ok && (this->poll_pending_ & POLL_BIND)) { + struct sockaddr_storage server {}; + socklen_t server_len = + socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), this->listen_address_, this->listen_port_); + // POSIX bind() on a link-local group needs it; lwIP ignores it + reinterpret_cast(&server)->sin6_scope_id = this->mcast_ifindex_; + if (this->listen_socket_->bind((struct sockaddr *) &server, server_len) == 0) { + this->poll_pending_ &= ~POLL_BIND; + this->poll_failed_ &= ~POLL_BIND; + } else { + this->set_poll_warning_(POLL_BIND, LOG_STR("Unable to bind socket")); + ok = false; + } + } +#endif +#ifdef USE_UDP_IPV6_SEND + // Only an IPv6 group's join supplies the index, so a failing IPv4 join must not hold back sends + const bool index_pending = (this->poll_pending_ & (POLL_JOIN | POLL_BIND)) == (POLL_JOIN | POLL_BIND); + if ((this->poll_pending_ & POLL_MCAST_IF) && !index_pending) { + // Index 0 picks the first eligible interface + if (socket::set_ipv6_multicast_if(this->send_socket_v6_.get(), this->mcast_ifindex_)) { + this->poll_pending_ &= ~POLL_MCAST_IF; + this->poll_failed_ &= ~POLL_MCAST_IF; + } else { + this->set_poll_warning_(POLL_MCAST_IF, LOG_STR("Failed to set IPv6 multicast interface")); + ok = false; + } + } +#endif + if (!ok) + return false; + if (this->poll_warning_) { + this->poll_warning_ = false; + this->status_clear_warning(); + } + return true; +} + +void UDPComponent::set_poll_warning_(PollStep step, const LogString *what) { + // Log the first failure of each step only; the poll keeps retrying every second + if (this->poll_failed_ & step) + return; + this->poll_failed_ |= step; + ESP_LOGW(TAG, "%s. Error %d", LOG_STR_ARG(what), errno); + // One warning bit: only a warning raised here is cleared on success, one from setup() stays + if (!this->status_has_warning()) { + this->status_set_warning(what); + this->poll_warning_ = true; + } } void UDPComponent::loop() { @@ -147,8 +240,23 @@ void UDPComponent::dump_config() { } void UDPComponent::send_packet(const uint8_t *data, size_t size) { - for (const auto &saddr : this->sockaddrs_) { - auto result = this->broadcast_socket_->sendto(data, size, 0, &saddr, sizeof(saddr)); + if (this->is_failed()) + return; // a send socket may be missing + for (const auto &entry : this->sockaddrs_) { +#ifdef USE_UDP_IPV6_SEND + const bool v6 = entry.sa.sa_family == AF_INET6; + // Until the egress interface is set, a multicast packet would leave on the wrong one + if (v6 && (this->poll_pending_ & POLL_MCAST_IF) && entry.in6.sin6_addr.s6_addr[0] == 0xff) { + ESP_LOGV(TAG, "Egress interface not set yet, multicast packet dropped"); + continue; + } + socket::UDPSendSocket *sock = v6 ? this->send_socket_v6_.get() : this->broadcast_socket_.get(); + socklen_t sockaddr_size = v6 ? sizeof(sockaddr_in6) : sizeof(sockaddr_in); +#else + socket::UDPSendSocket *sock = this->broadcast_socket_.get(); + socklen_t sockaddr_size = sizeof(sockaddr_in); +#endif + auto result = sock->sendto(data, size, 0, &entry.sa, sockaddr_size); if (result < 0) { ESP_LOGW(TAG, "sendto() error %d", errno); } diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 276fd5adf1..b4dd4b2ac4 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -22,6 +22,15 @@ class UDPComponent final : public Component { void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } void set_should_broadcast() { this->should_broadcast_ = true; } void set_should_listen() { this->should_listen_ = true; } +#ifdef USE_UDP_IPV6_SEND + /// This instance has IPv6 destinations; multicast ones also need an egress interface. + void set_ipv6_send(bool multicast) { + this->ipv6_send_ = true; + this->ipv6_multicast_ = multicast; + } + /// An instance whose destinations are all IPv6 needs no IPv4 send socket + void set_ipv4_send(bool ipv4_send) { this->ipv4_send_ = ipv4_send; } +#endif template void add_listener(F &&listener) { this->packet_listeners_.add(std::forward(listener)); } void setup() override; void loop() override; @@ -31,22 +40,43 @@ class UDPComponent final : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }; protected: - bool join_multicast_(); + enum PollStep : uint8_t { POLL_JOIN = 1, POLL_BIND = 2, POLL_MCAST_IF = 4 }; + union SockaddrEntry { + sockaddr sa; +#ifdef USE_UDP_IPV6_SEND + sockaddr_in6 in6; // sizes the storage for IPv6 destinations +#endif + }; + + /// One pass of the network poll; true once nothing is left to do. + bool network_poll_(); + void set_poll_warning_(PollStep step, const LogString *what); + + CallbackManager)> packet_listeners_{}; + std::unique_ptr broadcast_socket_ = nullptr; + std::unique_ptr listen_socket_ = nullptr; +#ifdef USE_UDP_IPV6_SEND + std::unique_ptr send_socket_v6_ = nullptr; +#endif + FixedVector sockaddrs_{}; + const char *const *addresses_{nullptr}; + const char *listen_address_{nullptr}; +#ifdef USE_SOCKET_IPV6 + uint32_t mcast_ifindex_{0}; +#endif uint16_t listen_port_{}; uint16_t broadcast_port_{}; bool should_broadcast_{}; bool should_listen_{}; - bool join_failed_{}; - bool join_warning_{}; - CallbackManager)> packet_listeners_{}; - - std::unique_ptr broadcast_socket_ = nullptr; - std::unique_ptr listen_socket_ = nullptr; - std::vector sockaddrs_{}; - const char *const *addresses_{nullptr}; - - const char *listen_address_{nullptr}; + uint8_t poll_pending_{}; // PollStep bits + uint8_t poll_failed_{}; // PollStep bits already logged + bool poll_warning_{}; +#ifdef USE_UDP_IPV6_SEND + bool ipv4_send_{true}; + bool ipv6_send_{}; + bool ipv6_multicast_{}; +#endif }; } // namespace esphome::udp diff --git a/tests/component_tests/udp/test_udp_listen_address.py b/tests/component_tests/udp/test_udp_listen_address.py new file mode 100644 index 0000000000..8c9718ca36 --- /dev/null +++ b/tests/component_tests/udp/test_udp_listen_address.py @@ -0,0 +1,135 @@ +"""Tests for the udp address validators.""" + +from ipaddress import ip_address + +import pytest + +from esphome.components.udp import ( + CONF_ADDRESSES, + CONF_LISTEN_ADDRESS, + _address, + _final_validate, + _listen_address, +) +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_IPV6, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.mark.parametrize("value", ["239.0.60.53", "255.255.255.255", "ff12::cafe"]) +def test_listen_address_accepts_multicast_and_broadcast(value: str) -> None: + assert str(_listen_address(value)) == value + + +@pytest.mark.parametrize( + ("value", "message"), + [ + # An IPv4 address must report the IPv4 error, not an IPv6 parse failure + ("192.168.1.10", "not a multicast address nor local broadcast address"), + ("fe80::1", "not an IPv6 multicast address"), + ("ff02::cafe%eth0", "carries a scope id"), + ("gg::1", "not a valid IPv6 address"), + ], +) +def test_listen_address_reports_matching_family_error(value: str, message: str) -> None: + with pytest.raises(cv.Invalid, match=message): + _listen_address(value) + + +@pytest.mark.parametrize( + "value", ["10.0.0.1", "255.255.255.255", "ff12::cafe", "2001:db8::1"] +) +def test_address_accepts_routable_destinations(value: str) -> None: + assert str(_address(value)) == value + + +@pytest.mark.parametrize( + ("value", "message"), + [ + # The device parses addresses with inet_pton, which takes no scope suffix + ("2001:db8::1%wlan0", "carries a scope id"), + # No scope id is set on the destination, so a link-local send would fail + ("fe80::1", "is link-local"), + ], +) +def test_address_rejects_unsendable_destinations(value: str, message: str) -> None: + with pytest.raises(cv.Invalid, match=message): + _address(value) + + +_RAW_LWIP_IPV6 = { + "network": {CONF_ENABLE_IPV6: True}, + "socket": {"implementation": "lwip_tcp"}, +} +_NO_IPV6 = "requires 'enable_ipv6: true'" +_RAW_LWIP = "not supported on this platform" + + +@pytest.mark.parametrize( + ("addresses", "listen_address", "platform", "full_config", "message"), + [ + pytest.param( + ["ff12::cafe"], + "255.255.255.255", + PlatformFramework.ESP32_IDF, + {}, + _NO_IPV6, + id="ipv6_destination_without_ipv6", + ), + pytest.param( + ["255.255.255.255"], + "ff12::cafe", + PlatformFramework.ESP32_IDF, + {}, + _NO_IPV6, + id="ipv6_listen_without_ipv6", + ), + pytest.param( + ["ff12::cafe"], + "255.255.255.255", + PlatformFramework.ESP8266_ARDUINO, + _RAW_LWIP_IPV6, + _RAW_LWIP, + id="ipv6_destination_on_raw_lwip", + ), + pytest.param( + ["255.255.255.255"], + "ff12::cafe", + PlatformFramework.ESP8266_ARDUINO, + _RAW_LWIP_IPV6, + _RAW_LWIP, + id="ipv6_listen_on_raw_lwip", + ), + ], +) +def test_final_validate_rejects_unsupported_ipv6( + set_core_config: SetCoreConfigCallable, + addresses: list[str], + listen_address: str, + platform: PlatformFramework, + full_config: dict, + message: str, +) -> None: + set_core_config(platform, full_config=full_config) + with pytest.raises(cv.Invalid, match=message): + _final_validate( + { + CONF_ADDRESSES: [ip_address(a) for a in addresses], + CONF_LISTEN_ADDRESS: ip_address(listen_address), + } + ) + + +def test_final_validate_accepts_ipv6_when_enabled( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"network": {CONF_ENABLE_IPV6: True}}, + ) + _final_validate( + { + CONF_ADDRESSES: [ip_address("ff12::cafe")], + CONF_LISTEN_ADDRESS: ip_address("ff12::cafe"), + } + ) diff --git a/tests/components/udp/common-ipv6.yaml b/tests/components/udp/common-ipv6.yaml new file mode 100644 index 0000000000..560d45bf11 --- /dev/null +++ b/tests/components/udp/common-ipv6.yaml @@ -0,0 +1,20 @@ +network: + enable_ipv6: true + +udp: + - id: my_udp_v6 + listen_address: ff12::cafe + addresses: + - "ff12::cafe" + - "2001:db8::1" + on_receive: + - udp.write: + id: my_udp_v6 + data: "pong" + # Default listen address: a dual-stack socket on IPv6 builds + - id: my_udp_dual_stack + port: 18512 + on_receive: + - logger.log: + format: "Received %zu bytes" + args: [data.size()] diff --git a/tests/components/udp/common.yaml b/tests/components/udp/common.yaml index f3a082fdd2..680e51df1c 100644 --- a/tests/components/udp/common.yaml +++ b/tests/components/udp/common.yaml @@ -3,30 +3,30 @@ wifi: password: password1 udp: - id: my_udp - listen_address: 239.0.60.53 - addresses: - - "239.0.60.53" - - "192.168.1.255" - - "10.0.0.255" - on_receive: - - logger.log: - format: "Received %zu bytes" - args: [data.size()] - - udp.write: - id: my_udp - data: "hello world" - - udp.write: - id: my_udp - data: !lambda |- - return std::vector{1,3,4,5,6}; - - udp.write: - id: my_udp - data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] - - udp.write: - id: my_udp - data: !lambda |- - return std::vector(data.begin(), data.end()); + - id: my_udp + listen_address: 239.0.60.53 + addresses: + - "239.0.60.53" + - "192.168.1.255" + - "10.0.0.255" + on_receive: + - logger.log: + format: "Received %zu bytes" + args: [data.size()] + - udp.write: + id: my_udp + data: "hello world" + - udp.write: + id: my_udp + data: !lambda |- + return std::vector{1,3,4,5,6}; + - udp.write: + id: my_udp + data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] + - udp.write: + id: my_udp + data: !lambda |- + return std::vector(data.begin(), data.end()); number: - platform: template @@ -43,8 +43,12 @@ button: on_press: then: - udp.write: + id: my_udp data: [0x01, 0x02, 0x03] - udp.write: + id: my_udp data: [0xAA, 0x55, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06] - - udp.write: !lambda |- - return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; + - udp.write: + id: my_udp + data: !lambda |- + return {0x10, 0x20, (uint8_t)id(udp_my_number).state}; diff --git a/tests/components/udp/test-ipv6.esp32-idf.yaml b/tests/components/udp/test-ipv6.esp32-idf.yaml new file mode 100644 index 0000000000..1ce3077e5f --- /dev/null +++ b/tests/components/udp/test-ipv6.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + udp: !include common.yaml + udp_ipv6: !include common-ipv6.yaml diff --git a/tests/components/udp/test.host.yaml b/tests/components/udp/test.host.yaml index 825d86c19e..98b56cbf79 100644 --- a/tests/components/udp/test.host.yaml +++ b/tests/components/udp/test.host.yaml @@ -13,3 +13,8 @@ udp: id: my_udp data: !lambda |- return std::vector{1,3,4,5,6}; + # Short forms without id: valid while there is a single udp instance + - udp.write: + data: [0x01, 0x02, 0x03] + - udp.write: !lambda |- + return std::vector(data.begin(), data.end()); diff --git a/tests/components/udp/test.nrf52-adafruit.yaml b/tests/components/udp/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..c5a9072a1d --- /dev/null +++ b/tests/components/udp/test.nrf52-adafruit.yaml @@ -0,0 +1,5 @@ +packages: + udp_ipv6: !include common-ipv6.yaml + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/integration/fixtures/udp_ipv6_unicast.yaml b/tests/integration/fixtures/udp_ipv6_unicast.yaml new file mode 100644 index 0000000000..32be96043c --- /dev/null +++ b/tests/integration/fixtures/udp_ipv6_unicast.yaml @@ -0,0 +1,24 @@ +esphome: + name: udp-ipv6-unicast-test + +host: + +api: + +network: + enable_ipv6: true + +logger: + level: DEBUG + +udp: + - id: test_udp + port: + listen_port: UDP_LISTEN_PORT_PLACEHOLDER + broadcast_port: UDP_REPLY_PORT_PLACEHOLDER + addresses: + - "::1" + on_receive: + - udp.write: + id: test_udp + data: "PONG" diff --git a/tests/integration/test_udp_ipv6.py b/tests/integration/test_udp_ipv6.py new file mode 100644 index 0000000000..19b8eb5fe4 --- /dev/null +++ b/tests/integration/test_udp_ipv6.py @@ -0,0 +1,63 @@ +"""Integration test for UDP over IPv6 with the default dual-stack listen socket.""" + +from __future__ import annotations + +import asyncio +import socket + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +def _free_port(family: socket.AddressFamily, host: str) -> int: + with socket.socket(family, socket.SOCK_DGRAM) as sock: + sock.bind((host, 0)) + return sock.getsockname()[1] + + +@pytest.mark.asyncio +async def test_udp_ipv6_unicast( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """IPv6 and IPv4 datagrams both reach the listen socket, and replies go out over IPv6.""" + if not socket.has_ipv6: + pytest.skip("Host has no IPv6 support") + try: + reply_sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + reply_sock.bind(("::1", 0)) + except OSError: + pytest.skip("Host has no IPv6 loopback") + reply_sock.setblocking(False) + reply_port = reply_sock.getsockname()[1] + listen_port = _free_port(socket.AF_INET6, "::") + config = yaml_config.replace( + "UDP_LISTEN_PORT_PLACEHOLDER", str(listen_port) + ).replace("UDP_REPLY_PORT_PLACEHOLDER", str(reply_port)) + + loop = asyncio.get_running_loop() + try: + async with ( + run_compiled(config), + api_client_connected() as client, + ): + assert await client.device_info() is not None + + for family, host in ( + (socket.AF_INET6, "::1"), + # An IPv4 sender reaches the IPv6 socket as a mapped address + (socket.AF_INET, "127.0.0.1"), + ): + with socket.socket(family, socket.SOCK_DGRAM) as send_sock: + send_sock.sendto(b"PING", (host, listen_port)) + try: + data = await asyncio.wait_for( + loop.sock_recv(reply_sock, 64), timeout=5.0 + ) + except TimeoutError: + pytest.fail(f"No reply over IPv6 to a PING sent to {host}") + assert data == b"PONG" + finally: + reply_sock.close() From 4e3845ba6878d111eec3dac226d28f8352d04148 Mon Sep 17 00:00:00 2001 From: Martin Klingensmith Date: Mon, 12 Oct 2026 15:20:13 -0600 Subject: [PATCH 207/268] [pd_pioneer_ir] Add Pioneer mini-split IR climate component (#17680) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/pd_pioneer_ir/__init__.py | 1 + esphome/components/pd_pioneer_ir/climate.py | 26 ++ .../pd_pioneer_ir/pd_pioneer_data.cpp | 111 ++++++ .../pd_pioneer_ir/pd_pioneer_data.h | 69 ++++ .../pd_pioneer_ir/pd_pioneer_ir.cpp | 59 +++ .../components/pd_pioneer_ir/pd_pioneer_ir.h | 30 ++ esphome/components/remote_base/__init__.py | 56 +++ .../remote_base/pd_pioneer_protocol.cpp | 57 +++ .../remote_base/pd_pioneer_protocol.h | 72 ++++ esphome/core/defines.h | 1 + tests/components/pd_pioneer_ir/__init__.py | 14 + tests/components/pd_pioneer_ir/benchmark.yaml | 2 + tests/components/pd_pioneer_ir/common.yaml | 6 + .../pd_pioneer_ir/pd_pioneer_ir_tests.cpp | 372 ++++++++++++++++++ .../pd_pioneer_ir/test.esp32-idf.yaml | 4 + .../pd_pioneer_ir/test.esp8266-ard.yaml | 4 + .../remote_receiver/common-actions.yaml | 5 + .../remote_receiver/esp32-common.yaml | 4 + .../remote_transmitter/common-buttons.yaml | 5 + 20 files changed, 899 insertions(+) create mode 100644 esphome/components/pd_pioneer_ir/__init__.py create mode 100644 esphome/components/pd_pioneer_ir/climate.py create mode 100644 esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp create mode 100644 esphome/components/pd_pioneer_ir/pd_pioneer_data.h create mode 100644 esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp create mode 100644 esphome/components/pd_pioneer_ir/pd_pioneer_ir.h create mode 100644 esphome/components/remote_base/pd_pioneer_protocol.cpp create mode 100644 esphome/components/remote_base/pd_pioneer_protocol.h create mode 100644 tests/components/pd_pioneer_ir/__init__.py create mode 100644 tests/components/pd_pioneer_ir/benchmark.yaml create mode 100644 tests/components/pd_pioneer_ir/common.yaml create mode 100644 tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp create mode 100644 tests/components/pd_pioneer_ir/test.esp32-idf.yaml create mode 100644 tests/components/pd_pioneer_ir/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 27aac4fb68..02e66742e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -417,6 +417,7 @@ esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman esphome/components/pcm5122/* @remcom +esphome/components/pd_pioneer_ir/* @martinsah esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 diff --git a/esphome/components/pd_pioneer_ir/__init__.py b/esphome/components/pd_pioneer_ir/__init__.py new file mode 100644 index 0000000000..3ca0ea4f78 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pd_pioneer_ir" diff --git a/esphome/components/pd_pioneer_ir/climate.py b/esphome/components/pd_pioneer_ir/climate.py new file mode 100644 index 0000000000..6bd3cc88d5 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/climate.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_USE_FAHRENHEIT +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@martinsah"] + +pd_pioneer_ir_ns = cg.esphome_ns.namespace("pd_pioneer_ir") +PDPioneerIR = pd_pioneer_ir_ns.class_("PDPioneerIR", climate_ir.ClimateIR) + + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(PDPioneerIR).extend( + { + cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, + # The unit's auto mode is encoded but untested, so it is opt-in + cv.Optional(climate_ir.CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean, + } +) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("pd_pioneer") # used from C++ + var = await climate_ir.new_climate_ir(config) + cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp b/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp new file mode 100644 index 0000000000..3d07d9006b --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp @@ -0,0 +1,111 @@ +#include "pd_pioneer_data.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::pd_pioneer_ir { + +// Default frames stay in flash; the constructor copies them with progmem_memcpy. Even byte 5 is PWR_ON, byte 6 +// MODE_COOL +static const uint8_t DEFAULT_ODD[] PROGMEM = {0x23, 0xCB, 0x26, 0x02, 0x00, 0x40, 0xC0, + 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00}; +static const uint8_t DEFAULT_EVEN[] PROGMEM = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, + 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80}; + +// Odd positions 5 and 6 and the even position 8 fan code, indexed from CLIMATE_FAN_AUTO to CLIMATE_FAN_HIGH +static const uint8_t FAN_CODES[][3] PROGMEM = { + {0x40, 0x20, 0x00}, {0x60, 0x40, 0x02}, {0x40, 0x60, 0x03}, {0x40, 0xC0, 0x05}}; +static constexpr size_t FAN_CODE_COUNT = sizeof(FAN_CODES) / sizeof(FAN_CODES[0]); + +ControlData::ControlData() : odd_(DEFAULT_ODD, sizeof(DEFAULT_ODD)), even_(DEFAULT_EVEN, sizeof(DEFAULT_EVEN)) {} + +void ControlData::set_temp(float temp_c) { + // The temperature travels as 31 - C in whole degrees at position 7, with bit 2 of position 12 adding a half + temp_c = clamp(temp_c, static_cast(PDPIONEER_TEMPC_MIN), static_cast(PDPIONEER_TEMPC_MAX)); + // Round to a quarter, then down to a half, so Fahrenheit set points encode as before + const int halves = static_cast(lroundf(temp_c * 4)) / 2; + this->even_[7] = static_cast(31 - halves / 2); + this->even_[12] = static_cast(0x80 | ((halves & 1) ? 0x04 : 0x00)); +} + +void ControlData::set_mode(ClimateMode mode) { + switch (mode) { + case ClimateMode::CLIMATE_MODE_OFF: + this->even_[5] = PWR_OFF; + this->even_[6] = MODE_HEAT; + this->even_[7] = 0x0C; + this->even_[12] = 0x80; + return; + case ClimateMode::CLIMATE_MODE_COOL: + this->even_[6] = MODE_COOL; + break; + case ClimateMode::CLIMATE_MODE_DRY: + this->even_[6] = MODE_DRY; + break; + case ClimateMode::CLIMATE_MODE_FAN_ONLY: + this->even_[6] = MODE_FAN_ONLY; + break; + case ClimateMode::CLIMATE_MODE_HEAT: + this->even_[6] = MODE_HEAT; + break; + default: + this->even_[6] = MODE_AUTO; + break; + } + this->even_[5] = PWR_ON; +} + +optional ControlData::get_mode(const PDPioneerData &even) { + if (even[5] == PWR_OFF) + return ClimateMode::CLIMATE_MODE_OFF; + switch (even[6]) { + case MODE_COOL: + return ClimateMode::CLIMATE_MODE_COOL; + case MODE_DRY: + return ClimateMode::CLIMATE_MODE_DRY; + case MODE_FAN_ONLY: + return ClimateMode::CLIMATE_MODE_FAN_ONLY; + case MODE_HEAT: + return ClimateMode::CLIMATE_MODE_HEAT; + case MODE_AUTO: + return ClimateMode::CLIMATE_MODE_HEAT_COOL; + default: + return {}; + } +} + +void ControlData::finalize() { + const size_t idx = this->fan_ >= climate::CLIMATE_FAN_AUTO && this->fan_ <= climate::CLIMATE_FAN_HIGH + ? this->fan_ - climate::CLIMATE_FAN_AUTO + : FAN_CODE_COUNT - 1; + this->odd_[5] = progmem_read_byte(&FAN_CODES[idx][0]); + this->odd_[6] = progmem_read_byte(&FAN_CODES[idx][1]); + this->odd_[7] = this->swing_; + this->even_[8] = static_cast(progmem_read_byte(&FAN_CODES[idx][2]) | + ((this->swing_ & SWING_VERTICAL) ? SWING_VERTICAL_EVEN_MASK : 0)); + this->odd_.finalize(); + this->even_.finalize(); +} + +ClimateFanMode ControlData::get_fan_mode(const PDPioneerData &even) { + const uint8_t code = even[8] & ~SWING_VERTICAL_EVEN_MASK; + for (size_t idx = 0; idx < FAN_CODE_COUNT; idx++) { + if (progmem_read_byte(&FAN_CODES[idx][2]) == code) + return static_cast(climate::CLIMATE_FAN_AUTO + idx); + } + return ClimateFanMode::CLIMATE_FAN_HIGH; +} + +void ControlData::set_swing_mode(climate::ClimateSwingMode mode) { + const bool vertical = mode == climate::CLIMATE_SWING_VERTICAL || mode == climate::CLIMATE_SWING_BOTH; + const bool horizontal = mode == climate::CLIMATE_SWING_HORIZONTAL || mode == climate::CLIMATE_SWING_BOTH; + this->swing_ = static_cast((vertical ? SWING_VERTICAL : 0) | (horizontal ? SWING_HORIZONTAL : 0)); +} + +climate::ClimateSwingMode ControlData::get_swing_mode(const PDPioneerData &even, bool horizontal) { + const bool vertical = (even[8] & SWING_VERTICAL_EVEN_MASK) == SWING_VERTICAL_EVEN_MASK; + if (vertical) + return horizontal ? climate::CLIMATE_SWING_BOTH : climate::CLIMATE_SWING_VERTICAL; + return horizontal ? climate::CLIMATE_SWING_HORIZONTAL : climate::CLIMATE_SWING_OFF; +} + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_data.h b/esphome/components/pd_pioneer_ir/pd_pioneer_data.h new file mode 100644 index 0000000000..ffea27dedd --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_data.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/components/remote_base/pd_pioneer_protocol.h" +#include "esphome/components/climate/climate_mode.h" +#include "esphome/core/helpers.h" + +namespace esphome::pd_pioneer_ir { + +constexpr uint8_t PDPIONEER_TEMPC_MIN = 16; +constexpr uint8_t PDPIONEER_TEMPC_MAX = 31; + +using climate::ClimateMode; +using climate::ClimateFanMode; +using remote_base::PDPioneerData; + +class ControlData { + public: + ControlData(); + void set_temp(float temp_c); + void set_mode(ClimateMode mode); + void set_fan_mode(ClimateFanMode mode) { this->fan_ = mode; } + void set_swing_mode(climate::ClimateSwingMode mode); + void set_eco(bool enabled) { + if (enabled) + this->even_[5] = PWR_ECO; + } + + /// Write the fan and swing positions, which share bytes, then the checksums + void finalize(); + const PDPioneerData &odd() const { return this->odd_; } + const PDPioneerData &even() const { return this->even_; } + + /// Clamped to the set point range, so a frame from a sibling remote cannot publish an impossible value + static float get_temp(const PDPioneerData &even) { + const float temp = ((even[12] & 0x04) ? 31.5f : 31.0f) - static_cast(even[7]); + return clamp(temp, static_cast(PDPIONEER_TEMPC_MIN), static_cast(PDPIONEER_TEMPC_MAX)); + } + /// Empty for a mode byte this remote does not use + static optional get_mode(const PDPioneerData &even); + static bool get_eco(const PDPioneerData &even) { return even[5] == PWR_ECO; } + static ClimateFanMode get_fan_mode(const PDPioneerData &even); + /// Only the odd burst carries horizontal swing; the even burst carries vertical swing + static bool get_horizontal_swing(const PDPioneerData &odd) { return (odd[7] & SWING_HORIZONTAL) == SWING_HORIZONTAL; } + static climate::ClimateSwingMode get_swing_mode(const PDPioneerData &even, bool horizontal); + + protected: + static constexpr uint8_t MODE_HEAT = 0x01; + static constexpr uint8_t MODE_DRY = 0x02; + static constexpr uint8_t MODE_COOL = 0x03; + static constexpr uint8_t MODE_FAN_ONLY = 0x07; + static constexpr uint8_t MODE_AUTO = 0x08; + + static constexpr uint8_t PWR_ON = 0x24; + static constexpr uint8_t PWR_ECO = 0x25; + static constexpr uint8_t PWR_OFF = 0xA0; + + static constexpr uint8_t SWING_VERTICAL = 0x08; + static constexpr uint8_t SWING_HORIZONTAL = 0x90; + /// Vertical swing also sets this mask on even frame position 8 (fan sub-code). + static constexpr uint8_t SWING_VERTICAL_EVEN_MASK = 0x38; + + PDPioneerData odd_; + PDPioneerData even_; + ClimateFanMode fan_{climate::CLIMATE_FAN_HIGH}; + /// Odd-burst position 7: SWING_VERTICAL and SWING_HORIZONTAL flags + uint8_t swing_{0}; +}; + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp new file mode 100644 index 0000000000..4ddcb3bc27 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp @@ -0,0 +1,59 @@ +#include "pd_pioneer_ir.h" +#include "esphome/core/log.h" + +namespace esphome::pd_pioneer_ir { + +ESPHOME_LOG_TAG(TAG, "pd_pioneer_ir"); + +void PDPioneerIR::setup() { + climate_ir::ClimateIR::setup(); + this->publish_state(); +} + +void PDPioneerIR::transmit_state() { + ControlData data; + data.set_mode(this->mode); + if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_FAN_ONLY) + data.set_temp(this->target_temperature); + data.set_fan_mode(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + data.set_swing_mode(this->swing_mode); + // ECO shares the power byte, so it must not undo an off command + data.set_eco(this->mode != climate::CLIMATE_MODE_OFF && this->preset == climate::CLIMATE_PRESET_ECO); + data.finalize(); + // The remote sends the two bursts as separate transmissions + this->transmit_(data.odd()); + this->transmit_(data.even()); +} + +bool PDPioneerIR::on_receive(remote_base::RemoteReceiveData data) { + auto frame = remote_base::PDPioneerProtocol().decode(data); + if (!frame.has_value()) + return false; + if (frame->is_odd_burst()) { + this->rx_horizontal_swing_ = ControlData::get_horizontal_swing(*frame); + return false; + } + // decode() only returns odd or even bursts; a mode byte this remote never sends is another device's frame + const optional decoded = ControlData::get_mode(*frame); + if (!decoded.has_value()) + return false; + // An auto-mode frame from the remote must not publish HEAT_COOL when the traits leave it out + if (this->get_traits().supports_mode(*decoded)) { + this->mode = *decoded; + } else { + ESP_LOGD(TAG, "Received mode %s is not enabled, keeping the current mode", + LOG_STR_ARG(climate::climate_mode_to_string(*decoded))); + } + // Off and fan-only bursts carry no target temperature + if (*decoded != climate::CLIMATE_MODE_OFF && *decoded != climate::CLIMATE_MODE_FAN_ONLY) + this->target_temperature = ControlData::get_temp(*frame); + this->fan_mode = ControlData::get_fan_mode(*frame); + this->swing_mode = ControlData::get_swing_mode(*frame, this->rx_horizontal_swing_); + // A missed odd burst must not carry this command's horizontal swing into the next one + this->rx_horizontal_swing_ = false; + this->preset = ControlData::get_eco(*frame) ? climate::CLIMATE_PRESET_ECO : climate::CLIMATE_PRESET_NONE; + this->publish_state(); + return true; +} + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h new file mode 100644 index 0000000000..38b325dc1e --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "pd_pioneer_data.h" + +namespace esphome::pd_pioneer_ir { + +class PDPioneerIR : public climate_ir::ClimateIR { + public: + PDPioneerIR() + : climate_ir::ClimateIR(PDPIONEER_TEMPC_MIN, PDPIONEER_TEMPC_MAX, 1.0f, true, true, + {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_NONE, climate::CLIMATE_PRESET_ECO}) {} + + void setup() override; + + void set_fahrenheit(bool value) { this->temperature_step_ = value ? 0.5f : 1.0f; } + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + // Horizontal swing from the odd burst, applied to the even burst that follows it + bool rx_horizontal_swing_{false}; +}; + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 9cf9493cd0..286d209e98 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2409,6 +2409,62 @@ register_protocol_action( ) +# PD Pioneer +( + PDPioneerData, + PDPioneerBinarySensor, + PDPioneerTrigger, + _, + PDPioneerDumper, +) = declare_protocol("PDPioneer") + + +def _validate_pd_pioneer_burst(code: list[int]) -> list[int]: + """Byte 3 tells the odd (0x02) and even (0x01) bursts apart; decode() drops anything else.""" + if code[3] not in (0x01, 0x02): + raise cv.Invalid("Byte 3 of a pd_pioneer code must be 0x01 or 0x02") + return code + + +PDPIONEER_SCHEMA = cv.Schema( + { + cv.Required(CONF_CODE): cv.All( + [cv.hex_uint8_t], cv.Length(min=13, max=14), _validate_pd_pioneer_burst + ), + } +) + + +def _pd_pioneer_finalized(code: list[int]) -> list[int]: + """Fill in the checksum: the sum of the first 13 bytes, plus 15 for an odd burst.""" + checksum = sum(code[:13], 15 if code[3] == 0x02 else 0) & 0xFF + return [*code[:13], checksum] + + +@register_binary_sensor("pd_pioneer", PDPioneerBinarySensor, PDPIONEER_SCHEMA) +def pd_pioneer_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add(var.set_data(_pd_pioneer_finalized(config[CONF_CODE]))) + + +@register_trigger("pd_pioneer", PDPioneerTrigger, PDPioneerData) +def pd_pioneer_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("pd_pioneer", PDPioneerDumper) +def pd_pioneer_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "pd_pioneer", + "PDPioneer", + PDPIONEER_SCHEMA, + _bytes_field(CONF_CODE, f"data = {PDPioneerData}({{}})"), + automation.ApplyCall("data.finalize()"), +) + + # Midea MideaData, MideaBinarySensor, MideaTrigger, _, MideaDumper = declare_protocol("Midea") MIDEA_SCHEMA = cv.Schema( diff --git a/esphome/components/remote_base/pd_pioneer_protocol.cpp b/esphome/components/remote_base/pd_pioneer_protocol.cpp new file mode 100644 index 0000000000..72568be649 --- /dev/null +++ b/esphome/components/remote_base/pd_pioneer_protocol.cpp @@ -0,0 +1,57 @@ +#include "pd_pioneer_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.pd_pioneer"); + +// Timings from captured Pronto codes (006D timebase, 26 us per word) +static constexpr uint32_t HEADER_MARK_US = 3120; +static constexpr uint32_t HEADER_SPACE_US = 1560; +static constexpr uint32_t BIT_MARK_US = 520; +static constexpr uint32_t BIT_ONE_SPACE_US = 1066; +static constexpr uint32_t BIT_ZERO_SPACE_US = 312; +static constexpr uint32_t FOOTER_MARK_US = 520; +static constexpr uint32_t FOOTER_SPACE_US = 10010; + +void PDPioneerProtocol::encode(RemoteTransmitData *dst, const PDPioneerData &src) { + dst->set_carrier_frequency(38000); + dst->reserve(2 + PDPioneerData::FRAME_SIZE * 8 * 2 + 2); + dst->item(HEADER_MARK_US, HEADER_SPACE_US); + for (uint8_t idx = 0; idx < PDPioneerData::FRAME_SIZE; idx++) { + for (uint8_t mask = 1; mask; mask <<= 1) + dst->item(BIT_MARK_US, (src[idx] & mask) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US); + } + dst->item(FOOTER_MARK_US, FOOTER_SPACE_US); +} + +optional PDPioneerProtocol::decode(RemoteReceiveData src) { + PDPioneerData out; + if (!src.expect_item(HEADER_MARK_US, HEADER_SPACE_US)) + return {}; + for (uint8_t idx = 0; idx < PDPioneerData::FRAME_SIZE; idx++) { + for (uint8_t mask = 1; mask; mask <<= 1) { + if (src.expect_item(BIT_MARK_US, BIT_ONE_SPACE_US)) { + out[idx] |= mask; + } else if (!src.expect_item(BIT_MARK_US, BIT_ZERO_SPACE_US)) { + return {}; + } + } + } + // The receiver's idle timeout usually ends the capture inside the footer space, so only the mark is required + if (!src.expect_mark(FOOTER_MARK_US)) + return {}; + if (!out.is_valid()) { + char buf[PDPioneerData::TO_STR_BUFFER_SIZE]; + ESP_LOGV(TAG, "Checksum mismatch: %s", out.to_str(buf)); + return {}; + } + return out; +} + +void PDPioneerProtocol::dump(const PDPioneerData &data) { + char buf[PDPioneerData::TO_STR_BUFFER_SIZE]; + ESP_LOGI(TAG, "Received PD-Pioneer: %s", data.to_str(buf)); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pd_pioneer_protocol.h b/esphome/components/remote_base/pd_pioneer_protocol.h new file mode 100644 index 0000000000..b324218f14 --- /dev/null +++ b/esphome/components/remote_base/pd_pioneer_protocol.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "remote_base.h" + +namespace esphome::remote_base { + +class PDPioneerData { + public: + static constexpr uint8_t FRAME_SIZE = 14; + static constexpr uint8_t OFFSET_CS = 13; + + PDPioneerData() = default; + PDPioneerData(std::initializer_list data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PDPioneerData(const std::vector &data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PDPioneerData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } + + uint8_t *data() { return this->data_.data(); } + const uint8_t *data() const { return this->data_.data(); } + + // Position 3 tells the two bursts of a command apart; the odd burst's checksum carries an extra 15 + bool is_odd_burst() const { return this->data_[3] == 0x02; } + bool is_even_burst() const { return this->data_[3] == 0x01; } + + bool is_valid() const { + return (this->is_odd_burst() || this->is_even_burst()) && this->data_[OFFSET_CS] == this->checksum_(); + } + + void finalize() { this->data_[OFFSET_CS] = this->checksum_(); } + + static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(FRAME_SIZE); + const char *to_str(char *buffer) const { + format_hex_pretty_to(buffer, TO_STR_BUFFER_SIZE, this->data_.data(), this->data_.size(), '.'); + return buffer; + } + + bool operator==(const PDPioneerData &rhs) const { return this->data_ == rhs.data_; } + + uint8_t &operator[](size_t idx) { return this->data_[idx]; } + const uint8_t &operator[](size_t idx) const { return this->data_[idx]; } + + protected: + uint8_t checksum_() const { + uint8_t cs = this->is_odd_burst() ? 15 : 0; + for (uint8_t idx = 0; idx < OFFSET_CS; idx++) + cs += this->data_[idx]; + return cs; + } + + std::array data_{}; +}; + +class PDPioneerProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const PDPioneerData &src); + optional decode(RemoteReceiveData src); + void dump(const PDPioneerData &data); +}; + +DECLARE_REMOTE_PROTOCOL(PDPioneer) + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3168d269cf..943458e3f6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -182,6 +182,7 @@ #define USE_REMOTE_PROTOCOL_NEXUS #define USE_REMOTE_PROTOCOL_ONKYORI #define USE_REMOTE_PROTOCOL_PANASONIC +#define USE_REMOTE_PROTOCOL_PD_PIONEER #define USE_REMOTE_PROTOCOL_PIONEER #define USE_REMOTE_PROTOCOL_PIONEER_WYT #define USE_REMOTE_PROTOCOL_PRONTO diff --git a/tests/components/pd_pioneer_ir/__init__.py b/tests/components/pd_pioneer_ir/__init__.py new file mode 100644 index 0000000000..b2d1d1c716 --- /dev/null +++ b/tests/components/pd_pioneer_ir/__init__.py @@ -0,0 +1,14 @@ +from esphome.components import remote_base +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # The platform's to_code never runs here; without this request remote_base drops the protocol source + remote_base.request_protocol("pd_pioneer") + + manifest.to_code = to_code_testing diff --git a/tests/components/pd_pioneer_ir/benchmark.yaml b/tests/components/pd_pioneer_ir/benchmark.yaml new file mode 100644 index 0000000000..5126d84934 --- /dev/null +++ b/tests/components/pd_pioneer_ir/benchmark.yaml @@ -0,0 +1,2 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test build +climate: [] diff --git a/tests/components/pd_pioneer_ir/common.yaml b/tests/components/pd_pioneer_ir/common.yaml new file mode 100644 index 0000000000..e799f7587f --- /dev/null +++ b/tests/components/pd_pioneer_ir/common.yaml @@ -0,0 +1,6 @@ +climate: + - platform: pd_pioneer_ir + name: Pioneer AC + transmitter_id: xmitr + receiver_id: rcvr + use_fahrenheit: true diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp new file mode 100644 index 0000000000..e7a5e2c18f --- /dev/null +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -0,0 +1,372 @@ +#include +#include +#include +#include + +#include "esphome/components/pd_pioneer_ir/pd_pioneer_ir.h" +#include "esphome/components/remote_base/pd_pioneer_protocol.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/preferences.h" + +namespace esphome::pd_pioneer_ir::testing { + +using remote_base::PDPioneerData; +using Frame = std::vector; + +static constexpr uint32_t TOLERANCE_PERCENT = 25; +static constexpr uint8_t FRAME_SIZE = PDPioneerData::FRAME_SIZE; + +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()); + } +}; + +/// Fill in the checksum: the sum of the first 13 values, plus 15 for an odd burst +static Frame finalized(Frame frame) { + uint8_t sum = frame[3] == 0x02 ? 15 : 0; + for (size_t idx = 0; idx < FRAME_SIZE - 1; idx++) + sum += frame[idx]; + frame[FRAME_SIZE - 1] = sum; + return frame; +} + +static Frame odd_frame(uint8_t fan5, uint8_t fan6, uint8_t swing) { + return finalized({0x23, 0xCB, 0x26, 0x02, 0x00, fan5, fan6, swing, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00}); +} + +static Frame even_frame(uint8_t power, uint8_t mode, uint8_t temp, uint8_t fan8, uint8_t byte12) { + return finalized({0x23, 0xCB, 0x26, 0x01, 0x00, power, mode, temp, fan8, 0x00, 0x00, 0x00, byte12, 0x00}); +} + +static remote_base::RawTimings encode(const Frame &frame) { + remote_base::RemoteTransmitData data; + remote_base::PDPioneerProtocol().encode(&data, PDPioneerData(frame)); + return data.get_data(); +} + +/// RemoteReceiveData keeps a reference to the timings, so they must outlive it +static remote_base::RemoteReceiveData rx(const remote_base::RawTimings &raw) { + return remote_base::RemoteReceiveData(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE); +} + +static Frame decode(const remote_base::RawTimings &raw) { + const auto decoded = remote_base::PDPioneerProtocol().decode(rx(raw)); + EXPECT_TRUE(decoded.has_value()); + if (!decoded.has_value()) + return {}; + return Frame(decoded->data(), decoded->data() + FRAME_SIZE); +} + +struct Fixture { + CapturingTransmitter transmitter; + PDPioneerIR climate; + + Fixture() { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_pd_pioneer_ir"; + 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(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + climate::ClimateSwingMode swing = climate::CLIMATE_SWING_OFF, + climate::ClimatePreset preset = climate::CLIMATE_PRESET_NONE) { + 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.set_preset(preset); + call.perform(); + } + + /// The odd and even bursts of the last command, decoded by the protocol + std::pair last_pair() const { + EXPECT_GE(this->transmitter.frames.size(), 2u); + if (this->transmitter.frames.size() < 2) + return {}; + const size_t n = this->transmitter.frames.size(); + return {decode(this->transmitter.frames[n - 2]), decode(this->transmitter.frames[n - 1])}; + } + + bool receive(const remote_base::RawTimings &raw) { + // The receiver calls through the listener interface, where on_receive is public + return static_cast(this->climate).on_receive(rx(raw)); + } + + bool receive(const Frame &frame) { return this->receive(encode(frame)); } +}; + +TEST(PDPioneerIR, ProtocolRoundTripsAFrame) { + const Frame frame = even_frame(0x24, 0x03, 0x07, 0x05, 0x80); + EXPECT_EQ(decode(encode(frame)), frame); + const Frame odd = odd_frame(0x40, 0xC0, 0x00); + EXPECT_EQ(decode(encode(odd)), odd); +} + +TEST(PDPioneerIR, ProtocolRejectsBadChecksumAndUnknownBurst) { + Frame frame = even_frame(0x24, 0x03, 0x07, 0x05, 0x80); + frame[13] ^= 0x01; + const remote_base::RawTimings bad = encode(frame); + EXPECT_FALSE(remote_base::PDPioneerProtocol().decode(rx(bad)).has_value()); + Frame unknown = even_frame(0x24, 0x03, 0x07, 0x05, 0x80); + unknown[3] = 0x03; + unknown = finalized(unknown); + const remote_base::RawTimings other = encode(unknown); + EXPECT_FALSE(remote_base::PDPioneerProtocol().decode(rx(other)).has_value()); +} + +TEST(PDPioneerIR, EncodesEachMode) { + struct Case { + climate::ClimateMode mode; + uint8_t code; + }; + // 24 C: 31.75 - 24 = 7.75, position 7 = 7, bit 2 of position 12 clear + static constexpr Case CASES[] = { + {climate::CLIMATE_MODE_COOL, 0x03}, + {climate::CLIMATE_MODE_HEAT, 0x01}, + {climate::CLIMATE_MODE_DRY, 0x02}, + {climate::CLIMATE_MODE_HEAT_COOL, 0x08}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(static_cast(c.mode)); + Fixture f; + f.command(c.mode, 24.0f, climate::CLIMATE_FAN_HIGH); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x40, 0xC0, 0x00)); + EXPECT_EQ(even, even_frame(0x24, c.code, 0x07, 0x05, 0x80)); + } +} + +TEST(PDPioneerIR, FanOnlyCarriesNoTemperature) { + Fixture f; + f.command(climate::CLIMATE_MODE_FAN_ONLY, 24.0f, climate::CLIMATE_FAN_LOW); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x60, 0x40, 0x00)); + // Position 7 keeps the template value 0x0B and position 8 follows the fan + EXPECT_EQ(even, even_frame(0x24, 0x07, 0x0B, 0x02, 0x80)); +} + +TEST(PDPioneerIR, EncodesOff) { + Fixture f; + f.command(climate::CLIMATE_MODE_OFF, 24.0f, climate::CLIMATE_FAN_HIGH); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x40, 0xC0, 0x00)); + EXPECT_EQ(even, even_frame(0xA0, 0x01, 0x0C, 0x05, 0x80)); +} + +TEST(PDPioneerIR, EncodesFanModes) { + struct Case { + climate::ClimateFanMode fan; + uint8_t odd5; + uint8_t odd6; + uint8_t even8; + }; + static constexpr Case CASES[] = { + {climate::CLIMATE_FAN_AUTO, 0x40, 0x20, 0x00}, + {climate::CLIMATE_FAN_LOW, 0x60, 0x40, 0x02}, + {climate::CLIMATE_FAN_MEDIUM, 0x40, 0x60, 0x03}, + {climate::CLIMATE_FAN_HIGH, 0x40, 0xC0, 0x05}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(static_cast(c.fan)); + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 24.0f, c.fan); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(c.odd5, c.odd6, 0x00)); + EXPECT_EQ(even, even_frame(0x24, 0x03, 0x07, c.even8, 0x80)); + } +} + +TEST(PDPioneerIR, EncodesSwing) { + struct Case { + climate::ClimateSwingMode swing; + uint8_t odd7; + uint8_t even8; + }; + // Vertical swing also ORs 0x38 into the fan field of the even burst (0x05 for high) + static constexpr Case CASES[] = { + {climate::CLIMATE_SWING_VERTICAL, 0x08, 0x3D}, + {climate::CLIMATE_SWING_HORIZONTAL, 0x90, 0x05}, + {climate::CLIMATE_SWING_BOTH, 0x98, 0x3D}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(static_cast(c.swing)); + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_HIGH, c.swing); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(odd, odd_frame(0x40, 0xC0, c.odd7)); + EXPECT_EQ(even, even_frame(0x24, 0x03, 0x07, c.even8, 0x80)); + } +} + +TEST(PDPioneerIR, SwingAndFanEncodeInAnyOrder) { + ControlData fan_first; + fan_first.set_fan_mode(climate::CLIMATE_FAN_LOW); + fan_first.set_swing_mode(climate::CLIMATE_SWING_BOTH); + fan_first.finalize(); + ControlData swing_first; + swing_first.set_swing_mode(climate::CLIMATE_SWING_BOTH); + swing_first.set_fan_mode(climate::CLIMATE_FAN_LOW); + swing_first.finalize(); + EXPECT_EQ(swing_first.odd(), fan_first.odd()); + EXPECT_EQ(swing_first.even(), fan_first.even()); + EXPECT_EQ(swing_first.odd()[7], 0x98); + EXPECT_EQ(swing_first.even()[8], 0x3A); +} + +TEST(PDPioneerIR, EncodesTemperatureInHalfDegrees) { + struct Case { + float temperature; + uint8_t byte7; + uint8_t byte12; + }; + // 31 - C in whole degrees, plus a bit for the half: 16 -> 15, 24.5 -> 7 with the bit set, 31 -> 0 + static constexpr Case CASES[] = { + {16.0f, 0x0F, 0x80}, {24.0f, 0x07, 0x80}, {24.5f, 0x07, 0x84}, + {31.0f, 0x00, 0x80}, {10.0f, 0x0F, 0x80}, {35.0f, 0x00, 0x80}, + }; + for (const auto &c : CASES) { + SCOPED_TRACE(c.temperature); + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, c.temperature, climate::CLIMATE_FAN_HIGH); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(even, even_frame(0x24, 0x03, c.byte7, 0x05, c.byte12)); + } +} + +TEST(PDPioneerIR, EncodesEcoPreset) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF, + climate::CLIMATE_PRESET_ECO); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(even, even_frame(0x25, 0x03, 0x07, 0x05, 0x80)); +} + +TEST(PDPioneerIR, OffWithEcoPresetStaysOff) { + Fixture f; + f.command(climate::CLIMATE_MODE_OFF, 24.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF, + climate::CLIMATE_PRESET_ECO); + const auto [odd, even] = f.last_pair(); + EXPECT_EQ(even, even_frame(0xA0, 0x01, 0x0C, 0x05, 0x80)); +} + +TEST(PDPioneerIR, ReceivesAnOddAndEvenPair) { + Fixture f; + // The odd burst alone changes nothing; the even burst completes the command + EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x98))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_TRUE(f.receive(even_frame(0x25, 0x01, 0x07, 0x3A, 0x84))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24.5f); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_BOTH); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); +} + +TEST(PDPioneerIR, ReceivesAnEvenBurstWithoutItsOddBurst) { + Fixture f; + // Position 8 is the medium fan code 0x03 with the vertical swing mask 0x38 + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x3B, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + // Horizontal swing from an earlier pair does not carry over to an even burst whose odd burst was missed + EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x98))); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x3A, 0x80))); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_BOTH); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x07, 0x00, 0x80))); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(PDPioneerIR, ReceivedUnknownModeIsIgnored) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + // Mode byte 0x05 belongs to no mode of this remote + EXPECT_FALSE(f.receive(even_frame(0x24, 0x05, 0x07, 0x05, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f); +} + +TEST(PDPioneerIR, ReceivedTemperatureIsClamped) { + Fixture f; + // Position 7 above 0x0F would decode below the 16 C minimum + EXPECT_TRUE(f.receive(even_frame(0x24, 0x03, 0x1F, 0x05, 0x80))); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 16.0f); +} + +TEST(PDPioneerIR, ReceivedAutoModeIsIgnoredWithoutHeatCool) { + Fixture f; + f.climate.set_supports_heat_cool(false); + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_FALSE(f.receive(odd_frame(0x60, 0x40, 0x08))); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x08, 0x07, 0x3A, 0x80))); + // The mode stays as it was; the rest of the command still applies + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24.0f); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(PDPioneerIR, ReceivedOffAndFanOnlyKeepTheTemperature) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_FALSE(f.receive(odd_frame(0x40, 0xC0, 0x00))); + EXPECT_TRUE(f.receive(even_frame(0xA0, 0x01, 0x0C, 0x03, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f); + EXPECT_FALSE(f.receive(odd_frame(0x40, 0xC0, 0x00))); + EXPECT_TRUE(f.receive(even_frame(0x24, 0x07, 0x0B, 0x05, 0x80))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.0f); +} + +TEST(PDPioneerIR, TransmittedCommandIsReceivedBack) { + Fixture sender; + sender.command(climate::CLIMATE_MODE_DRY, 19.0f, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_VERTICAL); + Fixture receiver; + const size_t n = sender.transmitter.frames.size(); + EXPECT_FALSE(receiver.receive(sender.transmitter.frames[n - 2])); + EXPECT_TRUE(receiver.receive(sender.transmitter.frames[n - 1])); + EXPECT_EQ(receiver.climate.mode, climate::CLIMATE_MODE_DRY); + EXPECT_FLOAT_EQ(receiver.climate.target_temperature, 19.0f); + EXPECT_EQ(receiver.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(receiver.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(PDPioneerIR, AutoFanIsReceivedBack) { + Fixture sender; + sender.command(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_AUTO); + Fixture receiver; + const size_t n = sender.transmitter.frames.size(); + EXPECT_FALSE(receiver.receive(sender.transmitter.frames[n - 2])); + EXPECT_TRUE(receiver.receive(sender.transmitter.frames[n - 1])); + EXPECT_EQ(receiver.climate.fan_mode, climate::CLIMATE_FAN_AUTO); +} + +TEST(PDPioneerIR, StaticCodeIsSentWithItsChecksum) { + // A 13-byte code, filled and finalized as the transmit action does + static const uint8_t CODE[] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x07, 0x05, 0x00, 0x00, 0x00, 0x80}; + remote_base::PDPioneerData data(CODE, sizeof(CODE)); + data.finalize(); + remote_base::RemoteTransmitData dst; + remote_base::PDPioneerProtocol().encode(&dst, data); + EXPECT_EQ(decode(dst.get_data()), even_frame(0x24, 0x03, 0x07, 0x05, 0x80)); +} + +} // namespace esphome::pd_pioneer_ir::testing diff --git a/tests/components/pd_pioneer_ir/test.esp32-idf.yaml b/tests/components/pd_pioneer_ir/test.esp32-idf.yaml new file mode 100644 index 0000000000..6172163ce4 --- /dev/null +++ b/tests/components/pd_pioneer_ir/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + pd_pioneer_ir: !include common.yaml diff --git a/tests/components/pd_pioneer_ir/test.esp8266-ard.yaml b/tests/components/pd_pioneer_ir/test.esp8266-ard.yaml new file mode 100644 index 0000000000..98fbf9714a --- /dev/null +++ b/tests/components/pd_pioneer_ir/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + pd_pioneer_ir: !include common.yaml diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 6aa7ae37bb..da88548d2a 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -192,6 +192,11 @@ on_hob2hood: - logger.log: format: "on_hob2hood: %u" args: ["x.command"] +on_pd_pioneer: + then: + - logger.log: + format: "on_pd_pioneer: burst %u power %u" + args: ["x[3]", "x[5]"] on_ira211: then: - logger.log: diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index 5062319040..edd5ee3647 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -62,3 +62,7 @@ binary_sensor: nexus: channel: 1 address: 0x42 + - platform: remote_receiver + name: PD Pioneer Cool + pd_pioneer: + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80] diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index b3583ffe12..66ee803d4f 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -531,3 +531,8 @@ button: repeat: times: 3 wait_time: 30ms + - platform: template + name: PD Pioneer + on_press: + - remote_transmitter.transmit_pd_pioneer: + code: [0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80] From 6bf091104fc395c9b6ab821ac5dbdf07b57f2b55 Mon Sep 17 00:00:00 2001 From: matt123p Date: Mon, 12 Oct 2026 22:22:17 +0100 Subject: [PATCH 208/268] [es7210] Add TDM support for future AEC support (#18954) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/es7210/__init__.py | 2 + esphome/components/es7210/audio_adc.py | 38 ++++++-- esphome/components/es7210/es7210.cpp | 31 +++++-- esphome/components/es7210/es7210.h | 13 ++- .../component_tests/es7210/test_audio_adc.py | 89 +++++++++++++++++++ .../components/es7210/test.esp32-s3-idf.yaml | 11 +++ 6 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 tests/component_tests/es7210/test_audio_adc.py create mode 100644 tests/components/es7210/test.esp32-s3-idf.yaml diff --git a/esphome/components/es7210/__init__.py b/esphome/components/es7210/__init__.py index 0038d29040..93bb826919 100644 --- a/esphome/components/es7210/__init__.py +++ b/esphome/components/es7210/__init__.py @@ -1 +1,3 @@ +"""ES7210 audio ADC component.""" + DOMAIN = "es7210" diff --git a/esphome/components/es7210/audio_adc.py b/esphome/components/es7210/audio_adc.py index 2defdb0c35..c2dd40b3e8 100644 --- a/esphome/components/es7210/audio_adc.py +++ b/esphome/components/es7210/audio_adc.py @@ -1,13 +1,18 @@ import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import esp32, i2c from esphome.components.audio_adc import AudioAdc +from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32S2 import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE +from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["i2c"] +CONF_CHANNEL_GAINS = "channel_gains" +CONF_TDM = "tdm" + es7210_ns = cg.esphome_ns.namespace("es7210") ES7210 = es7210_ns.class_("ES7210", AudioAdc, cg.Component, i2c.I2CDevice) @@ -21,24 +26,42 @@ ES7210_BITS_PER_SAMPLE_ENUM = { ES7210_MIC_GAINS = [0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 34.5, 36, 37.5] +ES7210_MIC_GAIN_DEFAULT = 24 _validate_bits = cv.float_with_unit("bits", "bit") -CONFIG_SCHEMA = ( + +def _validate_tdm(value: bool) -> bool: + """Reject TDM on ESP32 variants without TDM hardware support.""" + if value and CORE.is_esp32: + esp32.only_on_variant( + unsupported=[VARIANT_ESP32, VARIANT_ESP32S2], + msg_prefix="ES7210 TDM mode", + )(value) + return value + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(ES7210), cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( _validate_bits, cv.enum(ES7210_BITS_PER_SAMPLE_ENUM) ), - cv.Optional(CONF_MIC_GAIN, default="24db"): cv.All( + cv.Optional(CONF_MIC_GAIN): cv.All( cv.decibel, cv.one_of(*ES7210_MIC_GAINS) ), + cv.Optional(CONF_CHANNEL_GAINS): cv.All( + cv.ensure_list(cv.All(cv.decibel, cv.one_of(*ES7210_MIC_GAINS))), + cv.Length(min=4, max=4), + ), cv.Optional(CONF_SAMPLE_RATE, default=16000): cv.int_range(min=1), + cv.Optional(CONF_TDM, default=False): cv.All(cv.boolean, _validate_tdm), } ) .extend(cv.COMPONENT_SCHEMA) - .extend(i2c.i2c_device_schema(0x40)) + .extend(i2c.i2c_device_schema(0x40)), + cv.has_at_most_one_key(CONF_MIC_GAIN, CONF_CHANNEL_GAINS), ) @@ -48,5 +71,10 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) - cg.add(var.set_mic_gain(config[CONF_MIC_GAIN])) + if channel_gains := config.get(CONF_CHANNEL_GAINS): + for channel, gain in enumerate(channel_gains): + cg.add(var.set_channel_gain(channel, gain)) + else: + cg.add(var.set_mic_gain(config.get(CONF_MIC_GAIN, ES7210_MIC_GAIN_DEFAULT))) cg.add(var.set_sample_rate(config[CONF_SAMPLE_RATE])) + cg.add(var.set_tdm(config[CONF_TDM])) diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index 0354f719ef..fbabf7460f 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -27,8 +27,10 @@ void ES7210::dump_config() { ESP_LOGCONFIG(TAG, "ES7210 audio ADC:\n" " Bits Per Sample: %" PRIu8 "\n" - " Sample Rate: %" PRIu32, - this->bits_per_sample_, this->sample_rate_); + " Sample Rate: %" PRIu32 "\n" + " Channel Gains: %.1f dB, %.1f dB, %.1f dB, %.1f dB", + this->bits_per_sample_, this->sample_rate_, this->channel_gains_[0], this->channel_gains_[1], + this->channel_gains_[2], this->channel_gains_[3]); if (this->is_failed()) { ESP_LOGE(TAG, " Failed to initialize"); @@ -88,7 +90,17 @@ void ES7210::setup() { } bool ES7210::set_mic_gain(float mic_gain) { - this->mic_gain_ = clamp(mic_gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX); + this->channel_gains_.fill(clamp(mic_gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX)); + if (this->setup_complete_) { + return this->configure_mic_gain_(); + } + return true; +} + +bool ES7210::set_channel_gain(uint8_t channel, float gain) { + if (channel >= this->channel_gains_.size()) + return false; + this->channel_gains_[channel] = clamp(gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX); if (this->setup_complete_) { return this->configure_mic_gain_(); } @@ -133,7 +145,6 @@ bool ES7210::configure_sample_rate_() { } bool ES7210::configure_mic_gain_() { - auto regv = this->es7210_gain_reg_value_(this->mic_gain_); for (uint8_t i = 0; i < 4; ++i) { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43 + i, 0x10, 0x00)); } @@ -144,26 +155,30 @@ bool ES7210::configure_mic_gain_() { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC12_POWER_REG4B, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x10, 0x10)); - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x0f, regv)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[0]))); // Configure mic 2 ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC12_POWER_REG4B, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x10, 0x10)); - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[1]))); // Configure mic 3 // MIC3 uses the ADC3/4 and MIC3/4 clock domains (bits 2 and 4), not the MIC1/2 domains. ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x10, 0x10)); - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, regv)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[2]))); // Configure mic 4 ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x10, 0x10)); - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, regv)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[3]))); return true; } diff --git a/esphome/components/es7210/es7210.h b/esphome/components/es7210/es7210.h index 42c667b658..7278d5b3c4 100644 --- a/esphome/components/es7210/es7210.h +++ b/esphome/components/es7210/es7210.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/components/audio_adc/audio_adc.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" @@ -27,10 +29,15 @@ class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I void dump_config() override; void set_bits_per_sample(ES7210BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + /// Sets all four channel gains, replacing any configured per-channel gains. bool set_mic_gain(float mic_gain) override; + /// Sets a zero-based channel gain; returns false for an invalid channel or an I2C failure. + bool set_channel_gain(uint8_t channel, float gain); void set_sample_rate(uint32_t sample_rate) { this->sample_rate_ = sample_rate; } + void set_tdm(bool enable_tdm) { this->enable_tdm_ = enable_tdm; } - float mic_gain() override { return this->mic_gain_; }; + /// Returns the MIC1 gain; other channels may have different gains. + float mic_gain() override { return this->channel_gains_[0]; }; protected: /// @brief Updates an I2C registry address by modifying the current state @@ -50,8 +57,8 @@ class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I bool configure_sample_rate_(); bool setup_complete_{false}; - bool enable_tdm_{false}; // TDM is unsupported in ESPHome as of version 2024.12 - float mic_gain_{0}; + bool enable_tdm_{false}; + std::array channel_gains_{{0.0f, 0.0f, 0.0f, 0.0f}}; ES7210BitsPerSample bits_per_sample_{ES7210_BITS_PER_SAMPLE_16}; uint32_t sample_rate_{0}; }; diff --git a/tests/component_tests/es7210/test_audio_adc.py b/tests/component_tests/es7210/test_audio_adc.py new file mode 100644 index 0000000000..12a51eed2c --- /dev/null +++ b/tests/component_tests/es7210/test_audio_adc.py @@ -0,0 +1,89 @@ +"""Tests for ES7210 audio ADC configuration validation.""" + +import pytest + +from esphome.components.es7210.audio_adc import ( + CONF_CHANNEL_GAINS, + CONF_TDM, + CONFIG_SCHEMA, +) +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +import esphome.config_validation as cv +from esphome.const import CONF_MIC_GAIN, KEY_VARIANT, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_channel_gains_valid_config() -> None: + """Per-channel gains accept an AEC microphone and reference configuration.""" + config = CONFIG_SCHEMA({CONF_CHANNEL_GAINS: ["33db", "33db", "0db", "0db"]}) + + assert config[CONF_CHANNEL_GAINS] == [33.0, 33.0, 0.0, 0.0] + assert CONF_MIC_GAIN not in config + + +def test_omitted_gains_remain_absent() -> None: + """Code generation supplies the default when neither gain option is configured.""" + config = CONFIG_SCHEMA({}) + + assert CONF_MIC_GAIN not in config + assert CONF_CHANNEL_GAINS not in config + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32S2]) +def test_tdm_rejected_on_unsupported_variants( + variant: str, set_core_config: SetCoreConfigCallable +) -> None: + """ESP32 and ESP32-S2 cannot receive TDM audio.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + + with pytest.raises(cv.Invalid, match="ES7210 TDM mode is not available") as exc: + CONFIG_SCHEMA({CONF_TDM: True}) + + assert exc.value.path == [CONF_TDM] + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32S2]) +@pytest.mark.parametrize("tdm_config", [{}, {CONF_TDM: False}]) +def test_tdm_disabled_on_unsupported_variants( + variant: str, + tdm_config: dict[str, bool], + set_core_config: SetCoreConfigCallable, +) -> None: + """Unsupported variants still accept standard I2S mode.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + + assert CONFIG_SCHEMA(tdm_config)[CONF_TDM] is False + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32S3, VARIANT_ESP32C3, VARIANT_ESP32P4]) +def test_tdm_accepted_on_supported_variants( + variant: str, set_core_config: SetCoreConfigCallable +) -> None: + """Variants with TDM hardware accept TDM mode.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant}) + + assert CONFIG_SCHEMA({CONF_TDM: True})[CONF_TDM] is True + + +@pytest.mark.parametrize("channel_count", [3, 5]) +def test_channel_gains_requires_four_channels(channel_count: int) -> None: + """Channel gains must provide one value for each ES7210 input.""" + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_CHANNEL_GAINS: ["0db"] * channel_count}) + + +def test_channel_gains_and_mic_gain_are_mutually_exclusive() -> None: + """Uniform and per-channel gain configuration cannot be combined.""" + with pytest.raises(cv.Invalid, match="Cannot specify more than one"): + CONFIG_SCHEMA( + { + CONF_MIC_GAIN: "24db", + CONF_CHANNEL_GAINS: ["0db"] * 4, + } + ) diff --git a/tests/components/es7210/test.esp32-s3-idf.yaml b/tests/components/es7210/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..26409ccdae --- /dev/null +++ b/tests/components/es7210/test.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + +audio_adc: + - platform: es7210 + i2c_id: i2c_bus + id: es7210_adc + bits_per_sample: 16bit + channel_gains: [33db, 33db, 0db, 0db] + sample_rate: 16000 + tdm: true From 1c8808339aab2e77d5f4d6225cf93aee54015bcc Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 17:23:55 -0400 Subject: [PATCH 209/268] [power_management] Add the power_management domain with an esp32_power_management platform (#12325) Co-authored-by: Kevin Ahrendt Co-authored-by: Kevin Ahrendt Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + esphome/components/esp32/gpio.cpp | 4 + .../esp32_power_management/__init__.py | 1 + .../esp32_power_management.cpp | 75 ++++ .../esp32_power_management.h | 28 ++ .../power_management.py | 254 ++++++++++++ .../components/power_management/__init__.py | 16 + .../power_management/power_management.h | 9 + .../esp32_power_management/__init__.py | 1 + .../esp32_power_management/test_init.py | 361 ++++++++++++++++++ .../esp32_power_management/common.yaml | 11 + .../test.esp32-ard.yaml | 7 + .../test.esp32-h2-idf.yaml | 7 + .../test.esp32-idf.yaml | 7 + 14 files changed, 783 insertions(+) create mode 100644 esphome/components/esp32_power_management/__init__.py create mode 100644 esphome/components/esp32_power_management/esp32_power_management.cpp create mode 100644 esphome/components/esp32_power_management/esp32_power_management.h create mode 100644 esphome/components/esp32_power_management/power_management.py create mode 100644 esphome/components/power_management/__init__.py create mode 100644 esphome/components/power_management/power_management.h create mode 100644 tests/component_tests/esp32_power_management/__init__.py create mode 100644 tests/component_tests/esp32_power_management/test_init.py create mode 100644 tests/components/esp32_power_management/common.yaml create mode 100644 tests/components/esp32_power_management/test.esp32-ard.yaml create mode 100644 tests/components/esp32_power_management/test.esp32-h2-idf.yaml create mode 100644 tests/components/esp32_power_management/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 02e66742e5..b45816640e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -184,6 +184,7 @@ esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 esphome/components/esp32_hosted/update/* @swoboda1337 +esphome/components/esp32_power_management/* @rwrozelle esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core @@ -436,6 +437,7 @@ esphome/components/pn7160/* @jesserockz @kbx81 esphome/components/pn7160_i2c/* @jesserockz @kbx81 esphome/components/pn7160_spi/* @jesserockz @kbx81 esphome/components/pn71xx/* @jesserockz @kbx81 +esphome/components/power_management/* @rwrozelle esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core esphome/components/provisioning/* @esphome/core diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 59788e76dc..d9e303c973 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -164,6 +164,10 @@ void ESP32InternalGPIOPin::setup() { conf.pull_down_en = this->flags_ & gpio::FLAG_PULLDOWN ? GPIO_PULLDOWN_ENABLE : GPIO_PULLDOWN_DISABLE; conf.intr_type = GPIO_INTR_DISABLE; gpio_config(&conf); +#ifdef CONFIG_FREERTOS_USE_TICKLESS_IDLE + // If auto light sleep is used gpio sleep mode needs to be disabled for the pin + gpio_sleep_sel_dis(this->get_pin_num()); +#endif if (this->flags_ & gpio::FLAG_OUTPUT) { gpio_set_drive_capability(this->get_pin_num(), this->get_drive_strength()); } diff --git a/esphome/components/esp32_power_management/__init__.py b/esphome/components/esp32_power_management/__init__.py new file mode 100644 index 0000000000..76622032cd --- /dev/null +++ b/esphome/components/esp32_power_management/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_power_management" diff --git a/esphome/components/esp32_power_management/esp32_power_management.cpp b/esphome/components/esp32_power_management/esp32_power_management.cpp new file mode 100644 index 0000000000..41ba352e5a --- /dev/null +++ b/esphome/components/esp32_power_management/esp32_power_management.cpp @@ -0,0 +1,75 @@ +#include "esp32_power_management.h" +#ifdef USE_ESP32 +#include "esphome/core/log.h" + +#include "esp_private/esp_clk.h" +#include "soc/rtc.h" + +namespace esphome::esp32_power_management { + +ESPHOME_LOG_TAG(TAG, "esp32_power_management"); + +void ESP32PowerManagement::setup() { + if (this->max_freq_mhz_ == 0) + this->max_freq_mhz_ = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ; + if (this->min_freq_mhz_ == 0) + this->min_freq_mhz_ = esp_clk_xtal_freq() / MHZ; + // A low boot frequency (ESP32-H2 at 16 MHz) can sit below the crystal frequency. + if (this->min_freq_mhz_ > this->max_freq_mhz_) + this->min_freq_mhz_ = this->max_freq_mhz_; + + esp_pm_config_t pm_config = { + .max_freq_mhz = this->max_freq_mhz_, + .min_freq_mhz = this->min_freq_mhz_, +#if CONFIG_FREERTOS_USE_TICKLESS_IDLE + .light_sleep_enable = true, +#endif + }; + esp_err_t rc = esp_pm_configure(&pm_config); + if (rc != ESP_OK) { + // The usual cause is a minimum frequency that does not divide this board's crystal + ESP_LOGE(TAG, "esp_pm_configure failed: %s (crystal %u MHz)", esp_err_to_name(rc), + static_cast(esp_clk_xtal_freq() / MHZ)); + this->mark_failed(); + } +} + +void ESP32PowerManagement::dump_config() { + ESP_LOGCONFIG(TAG, + "Power Management:\n" + " Max Frequency: %uMHZ\n" + " Min Frequency: %uMHZ", + this->max_freq_mhz_, this->min_freq_mhz_); +#if SOC_CLK_XTAL32K_SUPPORTED + ESP_LOGCONFIG(TAG, " 32k XTAL: %s", + rtc_clk_slow_src_get() == SOC_RTC_SLOW_CLK_SRC_XTAL32K ? LOG_STR_LITERAL("in use") + : LOG_STR_LITERAL("not in use")); +#endif +#if CONFIG_FREERTOS_USE_TICKLESS_IDLE + ESP_LOGCONFIG(TAG, " Light Sleep Enabled"); +#if CONFIG_ESP_SLEEP_POWER_DOWN_FLASH + ESP_LOGCONFIG(TAG, " PM Flash Power Down in Light Sleep Enabled"); +#endif +#if CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP + ESP_LOGCONFIG(TAG, " PM Peripheral Power Down in Light Sleep Enabled"); +#endif +#if CONFIG_IEEE802154_SLEEP_ENABLE + ESP_LOGCONFIG(TAG, " ieee802154 Sleep Enabled"); +#endif +#if CONFIG_PM_PROFILING + ESP_LOGCONFIG(TAG, " PM Profiling Enabled"); +#endif +#if CONFIG_PM_TRACE + ESP_LOGCONFIG(TAG, " PM Trace Enabled"); +#endif +#if defined(CONFIG_LWIP_IPV6) && !defined(CONFIG_LWIP_ND6) + ESP_LOGCONFIG(TAG, " IPv6 Neighbor Discovery Disabled (CONFIG_LWIP_ND6)"); +#endif +#if CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION + ESP_LOGCONFIG(TAG, " USB Serial/JTAG Light Sleep Prevention Enabled"); +#endif +#endif +} + +} // namespace esphome::esp32_power_management +#endif diff --git a/esphome/components/esp32_power_management/esp32_power_management.h b/esphome/components/esp32_power_management/esp32_power_management.h new file mode 100644 index 0000000000..e6194e7681 --- /dev/null +++ b/esphome/components/esp32_power_management/esp32_power_management.h @@ -0,0 +1,28 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/power_management/power_management.h" +// Lets a YAML lambda call esp_pm_dump_locks(). +#include "esp_pm.h" + +namespace esphome::esp32_power_management { + +class ESP32PowerManagement : public power_management::PowerManagementComponent { + public: + float get_setup_priority() const override { return setup_priority::POWER; } + void setup() override; + void dump_config() override; + void set_max_freq_mhz(uint16_t max_freq_mhz) { this->max_freq_mhz_ = max_freq_mhz; } + void set_min_freq_mhz(uint16_t min_freq_mhz) { this->min_freq_mhz_ = min_freq_mhz; } + + protected: + // Zero means the build default; setup() replaces it with the value applied. + uint16_t max_freq_mhz_{0}; + uint16_t min_freq_mhz_{0}; +}; + +} // namespace esphome::esp32_power_management + +#endif diff --git a/esphome/components/esp32_power_management/power_management.py b/esphome/components/esp32_power_management/power_management.py new file mode 100644 index 0000000000..e9b6d6462c --- /dev/null +++ b/esphome/components/esp32_power_management/power_management.py @@ -0,0 +1,254 @@ +import logging + +import esphome.codegen as cg +from esphome.components import esp32, power_management +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + add_idf_sdkconfig_option, + get_esp32_variant, +) +from esphome.components.openthread.const import CONF_POLL_PERIOD +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN +from esphome.components.zigbee import DOMAIN as ZIGBEE_DOMAIN +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_OPENTHREAD, CONF_PLATFORM +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@rwrozelle"] +DEPENDENCIES = ["esp32"] + +CONF_ENABLE_LIGHT_SLEEP = "enable_light_sleep" +CONF_IDLE_TIME_BEFORE_SLEEP = "idle_time_before_sleep" +CONF_MAX_FREQUENCY = "max_frequency" +CONF_MIN_FREQUENCY = "min_frequency" +CONF_POWER_DOWN_FLASH = "power_down_flash" +CONF_POWER_DOWN_PERIPHERALS = "power_down_peripherals" +CONF_PROFILING = "profiling" +CONF_TRACE = "trace" + +# Variants with a TOP power domain, which can power down peripherals in light sleep. +_TOP_PD_VARIANTS = [ + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H21, + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S31, +] + +# Crystal frequencies each variant can be built for (ESP-IDF's SOC_XTAL_FREQ_* values). +# Below the PLL frequencies the CPU runs from the crystal through an integer divider. +_XTAL_MHZ = { + VARIANT_ESP32: (24, 26, 40), + VARIANT_ESP32C2: (26, 32, 40), + VARIANT_ESP32C3: (32, 40), + VARIANT_ESP32C5: (40, 48), + VARIANT_ESP32C6: (40,), + VARIANT_ESP32C61: (40,), + VARIANT_ESP32H2: (32,), + VARIANT_ESP32H21: (32,), + VARIANT_ESP32H4: (32,), + VARIANT_ESP32P4: (40,), + VARIANT_ESP32S2: (40,), + VARIANT_ESP32S3: (32, 40), + VARIANT_ESP32S31: (40,), +} +# esp_pm_configure() keeps the 1 MHz reference clock reachable: its divider must be +# at least 10 on the ESP32 and 2 on every other variant. +_MIN_FREQUENCY_FLOOR_MHZ = {VARIANT_ESP32: 10} + +esp32_power_management_ns = cg.esphome_ns.namespace("esp32_power_management") +PowerManagement = esp32_power_management_ns.class_( + "ESP32PowerManagement", power_management.PowerManagementComponent +) + + +def _mhz(value: object) -> int: + """Validate a frequency and keep it as whole megahertz.""" + return int(cv.frequency(value)) // 1_000_000 + + +def valid_min_frequencies(variant: str) -> list[int]: + """The minimum CPU frequencies esp_pm_configure() accepts on a variant, in MHz. + + A value must divide one of the variant's crystal frequencies, stay above the + reference clock floor, or be one of the PLL frequencies. + """ + floor = _MIN_FREQUENCY_FLOOR_MHZ.get(variant, 2) + mhz = { + xtal // divider + for xtal in _XTAL_MHZ[variant] + for divider in range(1, xtal // floor + 1) + if xtal % divider == 0 + } + mhz.update(int(freq[:-3]) for freq in esp32.CPU_FREQUENCIES[variant]) + return sorted(mhz) + + +def _validate(config: ConfigType) -> ConfigType: + variant = get_esp32_variant() + if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: + valid = esp32.CPU_FREQUENCIES[variant] + if f"{max_mhz}MHZ" not in valid: + raise cv.Invalid( + f"{max_mhz}MHZ is not a valid CPU frequency for {variant}. " + f"Valid options are: {', '.join(valid)}", + path=[CONF_MAX_FREQUENCY], + ) + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None and ( + min_mhz not in (valid_min := valid_min_frequencies(variant)) + ): + raise cv.Invalid( + f"{min_mhz}MHZ is not a valid minimum frequency for {variant}; it must " + f"divide the crystal frequency. Valid options are: " + f"{', '.join(f'{mhz}MHZ' for mhz in valid_min)}", + path=[CONF_MIN_FREQUENCY], + ) + if not config[CONF_ENABLE_LIGHT_SLEEP]: + for key in (CONF_POWER_DOWN_PERIPHERALS, CONF_POWER_DOWN_FLASH): + if config.get(key): + raise cv.Invalid( + f"{key} requires {CONF_ENABLE_LIGHT_SLEEP}: true", path=[key] + ) + config[CONF_POWER_DOWN_PERIPHERALS] = False + elif CONF_POWER_DOWN_PERIPHERALS not in config: + config[CONF_POWER_DOWN_PERIPHERALS] = variant in _TOP_PD_VARIANTS + elif config[CONF_POWER_DOWN_PERIPHERALS] and variant not in _TOP_PD_VARIANTS: + raise cv.Invalid( + f"{CONF_POWER_DOWN_PERIPHERALS} is only available on " + f"{', '.join(_TOP_PD_VARIANTS)}", + path=[CONF_POWER_DOWN_PERIPHERALS], + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PowerManagement), + cv.Optional(CONF_MAX_FREQUENCY): _mhz, + cv.Optional(CONF_MIN_FREQUENCY): _mhz, + cv.Optional(CONF_ENABLE_LIGHT_SLEEP, default=False): cv.boolean, + cv.Optional(CONF_IDLE_TIME_BEFORE_SLEEP, default=3): cv.int_range( + min=2, max=4294967295 + ), + cv.Optional(CONF_POWER_DOWN_PERIPHERALS): cv.boolean, + cv.Optional(CONF_POWER_DOWN_FLASH, default=False): cv.boolean, + cv.Optional(CONF_PROFILING, default=False): cv.boolean, + # Repurposes GPIOs as trace outputs, so keep it out of the visual editor. + cv.Optional( + CONF_TRACE, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.boolean, + } + ).extend(cv.COMPONENT_SCHEMA), + _validate, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await power_management.register_power_management(var, config) + add_idf_sdkconfig_option("CONFIG_PM_ENABLE", True) + + if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: + cg.add(var.set_max_freq_mhz(max_mhz)) + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None: + cg.add(var.set_min_freq_mhz(min_mhz)) + if config[CONF_PROFILING]: + add_idf_sdkconfig_option("CONFIG_PM_PROFILING", True) + if config[CONF_TRACE]: + add_idf_sdkconfig_option("CONFIG_PM_TRACE", True) + if not config[CONF_ENABLE_LIGHT_SLEEP]: + return + + # Automatic light sleep whenever no task is runnable. + add_idf_sdkconfig_option("CONFIG_FREERTOS_USE_TICKLESS_IDLE", True) + add_idf_sdkconfig_option("CONFIG_ESP_PHY_MAC_BB_PD", True) + # Kconfig's own "depends on" no-ops this where it doesn't apply. + add_idf_sdkconfig_option("CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION", True) + add_idf_sdkconfig_option( + "CONFIG_FREERTOS_IDLE_TIME_BEFORE_SLEEP", config[CONF_IDLE_TIME_BEFORE_SLEEP] + ) + if config[CONF_POWER_DOWN_PERIPHERALS]: + add_idf_sdkconfig_option("CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP", True) + if config[CONF_POWER_DOWN_FLASH]: + add_idf_sdkconfig_option("CONFIG_ESP_SLEEP_POWER_DOWN_FLASH", True) + + ot_conf = CORE.config.get(CONF_OPENTHREAD) + # A polling (sleepy) Thread end device cannot answer neighbour solicitations. + if ( + ot_conf is not None + and (poll_period := ot_conf.get(CONF_POLL_PERIOD)) is not None + and poll_period.total_milliseconds > 0 + ): + add_idf_sdkconfig_option("CONFIG_LWIP_ND6", False) + if ot_conf is not None or ZIGBEE_DOMAIN in CORE.config: + add_idf_sdkconfig_option("CONFIG_IEEE802154_SLEEP_ENABLE", True) + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + entries = full_config.get(power_management.DOMAIN, []) + if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in entries) > 1: + raise cv.Invalid(f"Only one {DOMAIN} instance is allowed") + boot_freq = full_config[esp32.DOMAIN][esp32.CONF_CPU_FREQUENCY] + boot_mhz = int(boot_freq[:-3]) + max_mhz = config.get(CONF_MAX_FREQUENCY) + # Without max_frequency the boot frequency is the ceiling esp_pm_configure() gets. + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None and min_mhz > ( + max_mhz if max_mhz is not None else boot_mhz + ): + raise cv.Invalid( + f"{CONF_MIN_FREQUENCY} must not be greater than the maximum frequency", + path=[CONF_MIN_FREQUENCY], + ) + if max_mhz is not None: + if boot_mhz > max_mhz: + _LOGGER.info( + "esp32.%s (%s) is higher than %s (%dMHZ); the CPU will be " + "downclocked to %dMHZ as soon as power management is set up", + esp32.CONF_CPU_FREQUENCY, + boot_freq, + CONF_MAX_FREQUENCY, + max_mhz, + max_mhz, + ) + elif boot_mhz < max_mhz: + _LOGGER.warning( + "esp32.%s (%s) is lower than %s (%dMHZ); the CPU may run as " + "high as %dMHZ under load once power management is set up", + esp32.CONF_CPU_FREQUENCY, + boot_freq, + CONF_MAX_FREQUENCY, + max_mhz, + max_mhz, + ) + if config[CONF_POWER_DOWN_FLASH] and PSRAM_DOMAIN in full_config: + raise cv.Invalid( + f"{CONF_POWER_DOWN_FLASH} is not allowed when the device has PSRAM", + path=[CONF_POWER_DOWN_FLASH], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/power_management/__init__.py b/esphome/components/power_management/__init__.py new file mode 100644 index 0000000000..5afbb648be --- /dev/null +++ b/esphome/components/power_management/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +IS_PLATFORM_COMPONENT = True +CODEOWNERS = ["@rwrozelle"] +DOMAIN = "power_management" + +power_management_ns = cg.esphome_ns.namespace("power_management") +PowerManagementComponent = power_management_ns.class_( + "PowerManagementComponent", cg.Component +) + + +async def register_power_management(var: MockObj, config: ConfigType) -> None: + await cg.register_component(var, config) diff --git a/esphome/components/power_management/power_management.h b/esphome/components/power_management/power_management.h new file mode 100644 index 0000000000..a0c026162a --- /dev/null +++ b/esphome/components/power_management/power_management.h @@ -0,0 +1,9 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::power_management { + +class PowerManagementComponent : public Component {}; + +} // namespace esphome::power_management diff --git a/tests/component_tests/esp32_power_management/__init__.py b/tests/component_tests/esp32_power_management/__init__.py new file mode 100644 index 0000000000..a543aec300 --- /dev/null +++ b/tests/component_tests/esp32_power_management/__init__.py @@ -0,0 +1 @@ +"""Tests for the esp32_power_management component.""" diff --git a/tests/component_tests/esp32_power_management/test_init.py b/tests/component_tests/esp32_power_management/test_init.py new file mode 100644 index 0000000000..32665060b7 --- /dev/null +++ b/tests/component_tests/esp32_power_management/test_init.py @@ -0,0 +1,361 @@ +"""Tests for esp32_power_management configuration validation and sdkconfig wiring.""" + +from contextlib import nullcontext +import logging +from unittest import mock + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import ( + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32C6, + VARIANT_ESP32H2, +) +from esphome.components.esp32_power_management.power_management import ( + CONF_ENABLE_LIGHT_SLEEP, + CONF_IDLE_TIME_BEFORE_SLEEP, + CONF_MAX_FREQUENCY, + CONF_MIN_FREQUENCY, + CONF_POWER_DOWN_FLASH, + CONF_POWER_DOWN_PERIPHERALS, + CONFIG_SCHEMA, + _final_validate, + to_code, + valid_min_frequencies, +) +from esphome.components.openthread.const import CONF_DEVICE_TYPE, CONF_POLL_PERIOD +from esphome.const import CONF_OPENTHREAD, KEY_FRAMEWORK_VERSION, PlatformFramework +from esphome.core import CORE, TimePeriodMilliseconds +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +LIGHT_SLEEP = {CONF_ENABLE_LIGHT_SLEEP: True} + + +def _set_variant( + set_core_config: SetCoreConfigCallable, + variant: str, + /, + *, + full_config: ConfigType | None = None, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)}, + platform_data={KEY_VARIANT: variant}, + full_config=full_config, + ) + + +@pytest.mark.parametrize( + ("variant", "config", "expectation"), + [ + pytest.param( + VARIANT_ESP32, + {CONF_MAX_FREQUENCY: "96MHZ"}, + pytest.raises(cv.Invalid, match="not a valid CPU frequency"), + id="max_invalid_for_variant", + ), + pytest.param( + VARIANT_ESP32, + {CONF_POWER_DOWN_PERIPHERALS: True}, + pytest.raises(cv.Invalid, match="requires enable_light_sleep"), + id="power_down_peripherals_without_light_sleep", + ), + pytest.param( + VARIANT_ESP32, + {CONF_POWER_DOWN_FLASH: True}, + pytest.raises(cv.Invalid, match="requires enable_light_sleep"), + id="power_down_flash_without_light_sleep", + ), + pytest.param( + VARIANT_ESP32, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: True}, + pytest.raises(cv.Invalid, match="only available on"), + id="power_down_peripherals_unsupported_variant", + ), + pytest.param( + VARIANT_ESP32H2, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: True}, + nullcontext(), + id="power_down_peripherals_top_pd_variant", + ), + # The ESP32 keeps its reference clock divider at 10 or more + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "8MHZ"}, + pytest.raises(cv.Invalid, match="not a valid minimum frequency"), + id="min_frequency_below_esp32_floor", + ), + # 26 MHz crystal modules exist, so 13 MHz is reachable on an ESP32 + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "13MHZ"}, + nullcontext(), + id="min_frequency_esp32_26mhz_crystal", + ), + pytest.param( + VARIANT_ESP32, + {CONF_MIN_FREQUENCY: "80MHZ"}, + nullcontext(), + id="min_frequency_pll", + ), + # The ESP32-H2 runs from a 32 MHz crystal, so 8 MHz divides it and 10 MHz does not + pytest.param( + VARIANT_ESP32H2, + {CONF_MIN_FREQUENCY: "8MHZ"}, + nullcontext(), + id="min_frequency_h2_crystal_divider", + ), + pytest.param( + VARIANT_ESP32H2, + {CONF_MIN_FREQUENCY: "10MHZ"}, + pytest.raises(cv.Invalid, match="must divide the crystal"), + id="min_frequency_h2_not_a_divider", + ), + pytest.param( + VARIANT_ESP32C6, + {CONF_MIN_FREQUENCY: "1MHZ"}, + pytest.raises(cv.Invalid, match="not a valid minimum frequency"), + id="min_frequency_below_reference_clock_floor", + ), + ], +) +def test_schema_validation( + set_core_config: SetCoreConfigCallable, + variant: str, + config: ConfigType, + expectation, +) -> None: + _set_variant(set_core_config, variant) + with expectation: + CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize( + ("variant", "expected"), + [ + pytest.param( + VARIANT_ESP32, [10, 12, 13, 20, 24, 26, 40, 80, 160, 240], id="esp32" + ), + pytest.param(VARIANT_ESP32H2, [2, 4, 8, 16, 32, 48, 64, 96], id="esp32h2"), + pytest.param( + VARIANT_ESP32C6, [2, 4, 5, 8, 10, 20, 40, 80, 120, 160], id="esp32c6" + ), + ], +) +def test_valid_min_frequencies(variant: str, expected: list[int]) -> None: + assert valid_min_frequencies(variant) == expected + + +def test_frequencies_are_kept_in_mhz(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32) + config = CONFIG_SCHEMA({CONF_MAX_FREQUENCY: "160MHZ", CONF_MIN_FREQUENCY: 40e6}) + assert config[CONF_MAX_FREQUENCY] == 160 + assert config[CONF_MIN_FREQUENCY] == 40 + assert config[CONF_IDLE_TIME_BEFORE_SLEEP] == 3 + + +@pytest.mark.parametrize( + ("variant", "config", "expected"), + [ + pytest.param(VARIANT_ESP32H2, LIGHT_SLEEP, True, id="top_pd_light_sleep"), + pytest.param(VARIANT_ESP32, LIGHT_SLEEP, False, id="no_top_pd_light_sleep"), + pytest.param(VARIANT_ESP32H2, {}, False, id="no_light_sleep"), + pytest.param( + VARIANT_ESP32H2, + LIGHT_SLEEP | {CONF_POWER_DOWN_PERIPHERALS: False}, + False, + id="explicit_value_kept", + ), + ], +) +def test_power_down_peripherals_default( + set_core_config: SetCoreConfigCallable, + variant: str, + config: ConfigType, + expected: bool, +) -> None: + _set_variant(set_core_config, variant) + assert CONFIG_SCHEMA(config)[CONF_POWER_DOWN_PERIPHERALS] is expected + + +def _final_validate_config( + set_core_config: SetCoreConfigCallable, + config: ConfigType, + full_config: ConfigType | None = None, + boot_frequency: str = "160MHZ", +) -> None: + """Validate config, then run _final_validate against the given full config.""" + full_config = {"esp32": {"cpu_frequency": boot_frequency}} | (full_config or {}) + _set_variant(set_core_config, VARIANT_ESP32, full_config=full_config) + _final_validate(CONFIG_SCHEMA(config)) + + +def test_rejects_multiple_esp32_power_management_instances( + set_core_config: SetCoreConfigCallable, +) -> None: + with pytest.raises(cv.Invalid, match="Only one esp32_power_management instance"): + _final_validate_config( + set_core_config, + {}, + {"power_management": [{"platform": "esp32_power_management"}] * 2}, + ) + + +@pytest.mark.parametrize( + ("max_frequency", "boot_frequency", "expected"), + [ + pytest.param("160MHZ", "240MHZ", "downclocked to 160MHZ", id="boot_higher"), + pytest.param("240MHZ", "80MHZ", "may run as high as 240MHZ", id="boot_lower"), + pytest.param("160MHZ", "160MHZ", "", id="equal"), + ], +) +def test_boot_frequency_log( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + max_frequency: str, + boot_frequency: str, + expected: str, +) -> None: + caplog.set_level(logging.INFO) + _final_validate_config( + set_core_config, + {CONF_MAX_FREQUENCY: max_frequency}, + boot_frequency=boot_frequency, + ) + assert expected in caplog.text + assert (caplog.text == "") is (expected == "") + + +@pytest.mark.parametrize( + ("config", "boot_frequency", "expectation"), + [ + pytest.param( + {CONF_MAX_FREQUENCY: "80MHZ", CONF_MIN_FREQUENCY: "160MHZ"}, + "240MHZ", + pytest.raises(cv.Invalid, match="must not be greater than"), + id="min_above_max", + ), + pytest.param( + {CONF_MIN_FREQUENCY: "160MHZ"}, + "80MHZ", + pytest.raises(cv.Invalid, match="must not be greater than"), + id="min_above_boot_frequency", + ), + pytest.param( + {CONF_MIN_FREQUENCY: "80MHZ"}, + "80MHZ", + nullcontext(), + id="min_equal_to_boot_frequency", + ), + ], +) +def test_min_frequency_against_effective_max( + set_core_config: SetCoreConfigCallable, + config: ConfigType, + boot_frequency: str, + expectation, +) -> None: + with expectation: + _final_validate_config(set_core_config, config, boot_frequency=boot_frequency) + + +def test_rejects_power_down_flash_with_psram( + set_core_config: SetCoreConfigCallable, +) -> None: + with pytest.raises(cv.Invalid, match="not allowed when the device has PSRAM"): + _final_validate_config( + set_core_config, + LIGHT_SLEEP | {CONF_POWER_DOWN_FLASH: True}, + {"psram": {"mode": "quad"}}, + ) + + +@pytest.fixture +def sdkconfig_calls() -> dict[str, object]: + """Run to_code() without codegen and collect the sdkconfig options it sets.""" + calls: dict[str, object] = {} + with ( + mock.patch( + "esphome.components.esp32_power_management.power_management.cg.new_Pvariable", + return_value=mock.MagicMock(), + ), + mock.patch( + "esphome.components.esp32_power_management.power_management.power_management." + "register_power_management", + new_callable=mock.AsyncMock, + ), + mock.patch( + "esphome.components.esp32_power_management.power_management.add_idf_sdkconfig_option", + side_effect=calls.__setitem__, + ), + ): + yield calls + + +SLEEPY_MTD = { + CONF_OPENTHREAD: { + CONF_DEVICE_TYPE: "MTD", + CONF_POLL_PERIOD: TimePeriodMilliseconds(milliseconds=1000), + } +} +NOT_SET = object() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("core_config", "light_sleep", "key", "expected"), + [ + pytest.param(SLEEPY_MTD, True, "CONFIG_LWIP_ND6", False, id="nd6_sleepy_mtd"), + pytest.param( + {CONF_OPENTHREAD: {CONF_DEVICE_TYPE: "FTD"}}, + True, + "CONFIG_LWIP_ND6", + NOT_SET, + id="nd6_ftd", + ), + pytest.param({}, True, "CONFIG_LWIP_ND6", NOT_SET, id="nd6_no_openthread"), + pytest.param( + {CONF_OPENTHREAD: {CONF_DEVICE_TYPE: "FTD"}}, + True, + "CONFIG_IEEE802154_SLEEP_ENABLE", + True, + id="radio_sleep_openthread", + ), + pytest.param( + {"zigbee": {"id": "zigbee_id"}}, + True, + "CONFIG_IEEE802154_SLEEP_ENABLE", + True, + id="radio_sleep_zigbee", + ), + pytest.param( + {}, True, "CONFIG_IEEE802154_SLEEP_ENABLE", NOT_SET, id="radio_sleep_none" + ), + pytest.param( + SLEEPY_MTD, False, "CONFIG_LWIP_ND6", NOT_SET, id="nd6_light_sleep_off" + ), + pytest.param( + SLEEPY_MTD, + False, + "CONFIG_IEEE802154_SLEEP_ENABLE", + NOT_SET, + id="radio_sleep_light_sleep_off", + ), + ], +) +async def test_to_code_sdkconfig( + set_core_config: SetCoreConfigCallable, + sdkconfig_calls: dict[str, object], + core_config: ConfigType, + light_sleep: bool, + key: str, + expected: object, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32H2) + CORE.config = core_config + await to_code(CONFIG_SCHEMA({CONF_ENABLE_LIGHT_SLEEP: light_sleep})) + assert sdkconfig_calls.get(key, NOT_SET) is expected diff --git a/tests/components/esp32_power_management/common.yaml b/tests/components/esp32_power_management/common.yaml new file mode 100644 index 0000000000..74d93e6577 --- /dev/null +++ b/tests/components/esp32_power_management/common.yaml @@ -0,0 +1,11 @@ +power_management: + - platform: esp32_power_management + id: pm_id + max_frequency: ${esp32_power_management_max_frequency} + min_frequency: ${esp32_power_management_min_frequency} + enable_light_sleep: true + idle_time_before_sleep: 3 + power_down_flash: true + power_down_peripherals: ${esp32_power_management_power_down_peripherals} + profiling: true + trace: true diff --git a/tests/components/esp32_power_management/test.esp32-ard.yaml b/tests/components/esp32_power_management/test.esp32-ard.yaml new file mode 100644 index 0000000000..2b25b2d23f --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-h2-idf.yaml b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml new file mode 100644 index 0000000000..c571a2be0c --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 96MHZ + esp32_power_management_min_frequency: 32MHZ + esp32_power_management_power_down_peripherals: "true" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-idf.yaml b/tests/components/esp32_power_management/test.esp32-idf.yaml new file mode 100644 index 0000000000..2b25b2d23f --- /dev/null +++ b/tests/components/esp32_power_management/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + +packages: + esp32_power_management: !include common.yaml From 2cd7c0eb36e98188894be0a48c400668f9ab31d1 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Mon, 12 Oct 2026 21:31:16 +0000 Subject: [PATCH 210/268] [husb238] Add HUSB238 USB PD controller hub with binary sensors (#16572) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/husb238/__init__.py | 33 ++++++++++++ esphome/components/husb238/binary_sensor.py | 38 ++++++++++++++ esphome/components/husb238/husb238.cpp | 51 +++++++++++++++++++ esphome/components/husb238/husb238.h | 28 ++++++++++ tests/components/husb238/common.yaml | 11 ++++ tests/components/husb238/test.esp32-idf.yaml | 3 ++ .../components/husb238/test.esp8266-ard.yaml | 3 ++ 8 files changed, 168 insertions(+) create mode 100644 esphome/components/husb238/__init__.py create mode 100644 esphome/components/husb238/binary_sensor.py create mode 100644 esphome/components/husb238/husb238.cpp create mode 100644 esphome/components/husb238/husb238.h create mode 100644 tests/components/husb238/common.yaml create mode 100644 tests/components/husb238/test.esp32-idf.yaml create mode 100644 tests/components/husb238/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b45816640e..168c543080 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -266,6 +266,7 @@ esphome/components/http_request/ota/* @oarcher esphome/components/http_request/update/* @jesserockz esphome/components/htu31d/* @betterengineering esphome/components/hub75/* @stuartparmenter +esphome/components/husb238/* @latonita esphome/components/hydreon_rgxx/* @functionpointer esphome/components/hyt271/* @Philippe12 esphome/components/i2c/* @esphome/core diff --git a/esphome/components/husb238/__init__.py b/esphome/components/husb238/__init__.py new file mode 100644 index 0000000000..92bf8c998f --- /dev/null +++ b/esphome/components/husb238/__init__.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] +DOMAIN = "husb238" +MULTI_CONF = True + +CONF_HUSB238_ID = "husb238_id" + +husb238_ns = cg.esphome_ns.namespace("husb238") +Husb238Component = husb238_ns.class_( + "Husb238Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Husb238Component), + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(0x08)) +) + + +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) diff --git a/esphome/components/husb238/binary_sensor.py b/esphome/components/husb238/binary_sensor.py new file mode 100644 index 0000000000..78d5d78a9d --- /dev/null +++ b/esphome/components/husb238/binary_sensor.py @@ -0,0 +1,38 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_NAME, DEVICE_CLASS_PLUG, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_HUSB238_ID, Husb238Component + +DEPENDENCIES = ["husb238"] + +CONF_ATTACHED = "attached" +CONF_CC_DIRECTION = "cc_direction" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HUSB238_ID): cv.use_id(Husb238Component), + cv.Optional(CONF_ATTACHED): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PLUG, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + key=CONF_NAME, + ), + cv.Optional(CONF_CC_DIRECTION): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + key=CONF_NAME, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HUSB238_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_ATTACHED, hub.set_attached_binary_sensor) + await sensors(CONF_CC_DIRECTION, hub.set_cc_direction_binary_sensor) diff --git a/esphome/components/husb238/husb238.cpp b/esphome/components/husb238/husb238.cpp new file mode 100644 index 0000000000..8ca841a7e4 --- /dev/null +++ b/esphome/components/husb238/husb238.cpp @@ -0,0 +1,51 @@ +#include "husb238.h" +#include "esphome/core/log.h" + +namespace esphome::husb238 { + +ESPHOME_LOG_TAG(TAG, "husb238"); + +void Husb238Component::setup() { + uint8_t status; + if (!this->read_byte(REG_PD_STATUS1, &status)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void Husb238Component::update() { + uint8_t status; + if (!this->read_byte(REG_PD_STATUS1, &status)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + +#ifdef USE_BINARY_SENSOR + if (this->attached_binary_sensor_ != nullptr) { + this->attached_binary_sensor_->publish_state((status & PD_STATUS1_ATTACHED) != 0); + } + if (this->cc_direction_binary_sensor_ != nullptr) { + // The CC line only means something while a source is attached + if (status & PD_STATUS1_ATTACHED) { + this->cc_direction_binary_sensor_->publish_state((status & PD_STATUS1_CC_DIR) != 0); + } else { + this->cc_direction_binary_sensor_->invalidate_state(); + } + } +#endif +} + +void Husb238Component::dump_config() { + ESP_LOGCONFIG(TAG, "HUSB238:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "PD Attached", this->attached_binary_sensor_); + LOG_BINARY_SENSOR(" ", "CC Direction", this->cc_direction_binary_sensor_); +#endif +} + +} // namespace esphome::husb238 diff --git a/esphome/components/husb238/husb238.h b/esphome/components/husb238/husb238.h new file mode 100644 index 0000000000..5a3513db02 --- /dev/null +++ b/esphome/components/husb238/husb238.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::husb238 { + +static constexpr uint8_t REG_PD_STATUS1 = 0x01; +static constexpr uint8_t PD_STATUS1_ATTACHED = 1 << 6; +static constexpr uint8_t PD_STATUS1_CC_DIR = 1 << 7; + +class Husb238Component : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(attached) + SUB_BINARY_SENSOR(cc_direction) +#endif +}; + +} // namespace esphome::husb238 diff --git a/tests/components/husb238/common.yaml b/tests/components/husb238/common.yaml new file mode 100644 index 0000000000..94e055acf8 --- /dev/null +++ b/tests/components/husb238/common.yaml @@ -0,0 +1,11 @@ +husb238: + id: husb238_hub + i2c_id: i2c_bus + +binary_sensor: + - platform: husb238 + husb238_id: husb238_hub + attached: "PD Attached" + cc_direction: + name: "CC Direction" + entity_category: diagnostic diff --git a/tests/components/husb238/test.esp32-idf.yaml b/tests/components/husb238/test.esp32-idf.yaml new file mode 100644 index 0000000000..a2286c6e51 --- /dev/null +++ b/tests/components/husb238/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + husb238: !include common.yaml diff --git a/tests/components/husb238/test.esp8266-ard.yaml b/tests/components/husb238/test.esp8266-ard.yaml new file mode 100644 index 0000000000..103b71b662 --- /dev/null +++ b/tests/components/husb238/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + husb238: !include common.yaml From 5a819f4b4807c4468f5c7557c56a52a00c8915a5 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Mon, 12 Oct 2026 21:34:04 +0000 Subject: [PATCH 211/268] [bh1745] Add BH1745 colour sensor (#6349) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bh1745/__init__.py | 75 ++++++++ esphome/components/bh1745/bh1745.cpp | 182 ++++++++++++++++++ esphome/components/bh1745/bh1745.h | 61 ++++++ esphome/components/bh1745/output/__init__.py | 28 +++ .../components/bh1745/output/bh1745_output.h | 16 ++ esphome/components/bh1745/sensor.py | 66 +++++++ tests/components/bh1745/common.yaml | 27 +++ tests/components/bh1745/test.esp32-idf.yaml | 3 + tests/components/bh1745/test.esp8266-ard.yaml | 3 + 10 files changed, 462 insertions(+) create mode 100644 esphome/components/bh1745/__init__.py create mode 100644 esphome/components/bh1745/bh1745.cpp create mode 100644 esphome/components/bh1745/bh1745.h create mode 100644 esphome/components/bh1745/output/__init__.py create mode 100644 esphome/components/bh1745/output/bh1745_output.h create mode 100644 esphome/components/bh1745/sensor.py create mode 100644 tests/components/bh1745/common.yaml create mode 100644 tests/components/bh1745/test.esp32-idf.yaml create mode 100644 tests/components/bh1745/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 168c543080..2d3f8af71d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -65,6 +65,7 @@ esphome/components/bedjet/climate/* @jhansche esphome/components/bedjet/fan/* @jhansche esphome/components/bedjet/sensor/* @javawizard @jhansche esphome/components/beken_spi_led_strip/* @Mat931 +esphome/components/bh1745/* @latonita esphome/components/bh1750/* @OttoWinter esphome/components/bh1900nux/* @B48D81EFCC esphome/components/binary_sensor/* @esphome/core diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py new file mode 100644 index 0000000000..bd63fd4ef2 --- /dev/null +++ b/esphome/components/bh1745/__init__.py @@ -0,0 +1,75 @@ +from typing import Any + +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_GAIN, + CONF_GLASS_ATTENUATION_FACTOR, + CONF_ID, + CONF_INTEGRATION_TIME, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bh1745" +MULTI_CONF = True + +CONF_BH1745_ID = "bh1745_id" + +bh1745_ns = cg.esphome_ns.namespace("bh1745") + +BH1745Component = bh1745_ns.class_( + "BH1745Component", cg.PollingComponent, i2c.I2CDevice +) + +AdcGain = bh1745_ns.enum("AdcGain", is_class=True) +ADC_GAINS = { + "1X": AdcGain.ADC_GAIN_1X, + "2X": AdcGain.ADC_GAIN_2X, + "16X": AdcGain.ADC_GAIN_16X, +} + +MeasurementTime = bh1745_ns.enum("MeasurementTime", is_class=True) +MEASUREMENT_TIMES = { + 160: MeasurementTime.MEASUREMENT_TIME_160MS, + 320: MeasurementTime.MEASUREMENT_TIME_320MS, + 640: MeasurementTime.MEASUREMENT_TIME_640MS, + 1280: MeasurementTime.MEASUREMENT_TIME_1280MS, + 2560: MeasurementTime.MEASUREMENT_TIME_2560MS, + 5120: MeasurementTime.MEASUREMENT_TIME_5120MS, +} + + +def validate_measurement_time(value: Any) -> Any: + value = cv.positive_time_period_milliseconds(value).total_milliseconds + return cv.enum(MEASUREMENT_TIMES, int=True)(value) + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BH1745Component), + cv.Optional(CONF_GAIN, default="1X"): cv.enum(ADC_GAINS, upper=True), + cv.Optional( + CONF_INTEGRATION_TIME, default="160ms" + ): validate_measurement_time, + cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.float_range( + min=1.0 + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x38)) +) + + +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) + + cg.add(var.set_adc_gain(config[CONF_GAIN])) + cg.add(var.set_measurement_time(config[CONF_INTEGRATION_TIME])) + cg.add(var.set_glass_attenuation_factor(config[CONF_GLASS_ATTENUATION_FACTOR])) diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp new file mode 100644 index 0000000000..24744c3330 --- /dev/null +++ b/esphome/components/bh1745/bh1745.cpp @@ -0,0 +1,182 @@ +#include "bh1745.h" +#include +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bh1745 { + +ESPHOME_LOG_TAG(TAG, "bh1745"); + +static constexpr uint8_t REG_SYSTEM_CONTROL = 0x40; +static constexpr uint8_t REG_MODE_CONTROL1 = 0x41; +static constexpr uint8_t REG_MODE_CONTROL2 = 0x42; +static constexpr uint8_t REG_MODE_CONTROL3 = 0x44; +static constexpr uint8_t REG_RED_DATA_LSB = 0x50; +static constexpr uint8_t REG_INTERRUPT = 0x60; +static constexpr uint8_t REG_TH_LSB = 0x62; +static constexpr uint8_t REG_MANUFACTURER_ID = 0x92; + +static constexpr uint8_t MANUFACTURER_ID = 0xE0; +static constexpr uint8_t PART_ID = 0b001011; +static constexpr uint8_t SYSTEM_CONTROL_PART_ID_MASK = 0x3F; +static constexpr uint8_t SYSTEM_CONTROL_SW_RESET = 0x80; +static constexpr uint8_t MODE_CONTROL2_RGBC_EN = 0x10; +static constexpr uint8_t MODE_CONTROL2_VALID = 0x80; +static constexpr uint8_t MODE_CONTROL3_VALUE = 0x02; // the datasheet says always write 0x02 +static constexpr uint8_t INTERRUPT_ENABLE = 0x01; + +static constexpr uint8_t BH1745_CHANNELS = 4; +static constexpr uint32_t RESET_DELAY_MS = 100; +static constexpr uint32_t BASE_MEAS_TIME_MS = 160; + +static uint32_t get_measurement_time_ms(MeasurementTime time) { + return BASE_MEAS_TIME_MS << static_cast(time); +} + +static uint8_t get_adc_gain(AdcGain gain) { + return gain == AdcGain::ADC_GAIN_16X ? 16 : 1 + static_cast(gain); +} + +static float calculate_lux(uint16_t red, uint16_t green, bool low_clear_ratio, float scale) { + if (green == 0) + return 0.0f; + float lx = low_clear_ratio ? 0.202f * red + 0.766f * green : 0.159f * red + 0.646f * green; + return lx * scale; +} + +static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low_clear_ratio) { + if (green == 0) + return 0.0f; + float all = static_cast(static_cast(red) + green + blue); + float r_ratio = red / all; + float b_ratio = blue / all; + float ct; + if (low_clear_ratio) { + float b_eff = std::fmin(b_ratio * 3.13f, 1.0f); + ct = ((1.0f - b_eff) * 12746.0f * std::exp(-2.911f * r_ratio)) + (b_eff * 1637.0f * std::exp(4.865f * b_ratio)); + } else { + float b_eff = std::fmin(b_ratio * 10.67f, 1.0f); + ct = ((1.0f - b_eff) * 16234.0f * std::exp(-2.781f * r_ratio)) + (b_eff * 1882.0f * std::exp(4.448f * b_ratio)); + } + return clamp_at_most(ct, 10000.0f); +} + +void BH1745Component::setup() { + uint8_t manufacturer_id; + if (!this->read_byte(REG_MANUFACTURER_ID, &manufacturer_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (manufacturer_id != MANUFACTURER_ID) { + this->mark_failed(LOG_STR("Wrong manufacturer ID")); + return; + } + uint8_t system_control; + if (!this->read_byte(REG_SYSTEM_CONTROL, &system_control) || + (system_control & SYSTEM_CONTROL_PART_ID_MASK) != PART_ID) { + this->mark_failed(LOG_STR("Wrong part ID")); + return; + } + if (!this->write_byte(REG_SYSTEM_CONTROL, SYSTEM_CONTROL_SW_RESET)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Light lost behind glass is scaled back up, as the other light sensors do + this->lux_scale_ = 160.0f * this->glass_attenuation_factor_ / + (get_adc_gain(this->adc_gain_) * get_measurement_time_ms(this->measurement_time_)); + + this->set_timeout(RESET_DELAY_MS, [this]() { + if (this->interrupt_output_) { + // High threshold 0 and low threshold 0xFFFF (low byte first) make the interrupt condition always + // true, so the INT pin simply follows the enable bit, which drives the breakout's LEDs. + static constexpr uint8_t THRESHOLDS[4] = {0x00, 0x00, 0xFF, 0xFF}; + if (!this->write_bytes(REG_TH_LSB, THRESHOLDS, sizeof(THRESHOLDS))) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + } + // RGBC_EN makes the chip measure continuously; update() reads the latest result. + if (!this->write_byte(REG_MODE_CONTROL1, static_cast(this->measurement_time_)) || + !this->write_byte(REG_MODE_CONTROL2, static_cast(this->adc_gain_) | MODE_CONTROL2_RGBC_EN) || + !this->write_byte(REG_MODE_CONTROL3, MODE_CONTROL3_VALUE)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->busy_ = false; + }); +} + +void BH1745Component::dump_config() { + ESP_LOGCONFIG(TAG, + "BH1745:\n" + " Gain: %ux\n" + " Integration time: %" PRIu32 " ms\n" + " Glass attenuation factor: %.2f", + get_adc_gain(this->adc_gain_), get_measurement_time_ms(this->measurement_time_), + this->glass_attenuation_factor_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + + LOG_SENSOR(" ", "Red Counts", this->red_counts_sensor_); + LOG_SENSOR(" ", "Green Counts", this->green_counts_sensor_); + LOG_SENSOR(" ", "Blue Counts", this->blue_counts_sensor_); + LOG_SENSOR(" ", "Clear Counts", this->clear_counts_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Color Temperature", this->color_temperature_sensor_); +} + +void BH1745Component::update() { + if (this->busy_) + return; + + // Reading MODE_CONTROL2 clears VALID, so read it only once per poll. + uint8_t mode_control2; + if (!this->read_byte(REG_MODE_CONTROL2, &mode_control2)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if ((mode_control2 & MODE_CONTROL2_VALID) == 0) + return; + + uint8_t buffer[BH1745_CHANNELS * 2]; + if (!this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + + // Each channel is sent low byte first. + const uint16_t red = encode_uint16(buffer[1], buffer[0]); + const uint16_t green = encode_uint16(buffer[3], buffer[2]); + const uint16_t blue = encode_uint16(buffer[5], buffer[4]); + const uint16_t clear = encode_uint16(buffer[7], buffer[6]); + ESP_LOGV(TAG, "Red: %u, Green: %u, Blue: %u, Clear: %u", red, green, blue, clear); + + if (this->red_counts_sensor_ != nullptr) + this->red_counts_sensor_->publish_state(red); + if (this->green_counts_sensor_ != nullptr) + this->green_counts_sensor_->publish_state(green); + if (this->blue_counts_sensor_ != nullptr) + this->blue_counts_sensor_->publish_state(blue); + if (this->clear_counts_sensor_ != nullptr) + this->clear_counts_sensor_->publish_state(clear); + + // The datasheet picks its coefficients by whether clear / green < 0.160 (= 4 / 25). + const bool low_clear_ratio = static_cast(clear) * 25 < static_cast(green) * 4; + if (this->illuminance_sensor_ != nullptr) + this->illuminance_sensor_->publish_state(calculate_lux(red, green, low_clear_ratio, this->lux_scale_)); + if (this->color_temperature_sensor_ != nullptr) + this->color_temperature_sensor_->publish_state(calculate_cct(red, green, blue, low_clear_ratio)); +} + +void BH1745Component::set_interrupt_state(bool on) { + if (this->is_failed()) + return; + if (!this->write_byte(REG_INTERRUPT, on ? INTERRUPT_ENABLE : 0x00)) { + this->status_set_warning(); + } +} + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/bh1745.h b/esphome/components/bh1745/bh1745.h new file mode 100644 index 0000000000..06d0987d67 --- /dev/null +++ b/esphome/components/bh1745/bh1745.h @@ -0,0 +1,61 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::bh1745 { + +enum class MeasurementTime : uint8_t { + MEASUREMENT_TIME_160MS = 0b000, + MEASUREMENT_TIME_320MS = 0b001, + MEASUREMENT_TIME_640MS = 0b010, + MEASUREMENT_TIME_1280MS = 0b011, + MEASUREMENT_TIME_2560MS = 0b100, + MEASUREMENT_TIME_5120MS = 0b101, +}; + +enum class AdcGain : uint8_t { + ADC_GAIN_1X = 0, + ADC_GAIN_2X = 1, + ADC_GAIN_16X = 2, +}; + +class BH1745Component : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void update() override; + + void set_measurement_time(MeasurementTime measurement_time) { this->measurement_time_ = measurement_time; } + void set_adc_gain(AdcGain adc_gain) { this->adc_gain_ = adc_gain; } + void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; } + + void set_red_counts_sensor(sensor::Sensor *red) { this->red_counts_sensor_ = red; } + void set_green_counts_sensor(sensor::Sensor *green) { this->green_counts_sensor_ = green; } + void set_blue_counts_sensor(sensor::Sensor *blue) { this->blue_counts_sensor_ = blue; } + void set_clear_counts_sensor(sensor::Sensor *clear) { this->clear_counts_sensor_ = clear; } + void set_illuminance_sensor(sensor::Sensor *illuminance) { this->illuminance_sensor_ = illuminance; } + void set_color_temperature_sensor(sensor::Sensor *cct) { this->color_temperature_sensor_ = cct; } + + /// Called when an output platform uses the INT pin, so setup() pins the interrupt thresholds. + void enable_interrupt_output() { this->interrupt_output_ = true; } + void set_interrupt_state(bool on); + + protected: + sensor::Sensor *red_counts_sensor_{nullptr}; + sensor::Sensor *green_counts_sensor_{nullptr}; + sensor::Sensor *blue_counts_sensor_{nullptr}; + sensor::Sensor *clear_counts_sensor_{nullptr}; + sensor::Sensor *illuminance_sensor_{nullptr}; + sensor::Sensor *color_temperature_sensor_{nullptr}; + + float glass_attenuation_factor_{1.0f}; + float lux_scale_{1.0f}; // 160 / (gain * integration time * glass attenuation), set in setup() + MeasurementTime measurement_time_{MeasurementTime::MEASUREMENT_TIME_160MS}; + AdcGain adc_gain_{AdcGain::ADC_GAIN_1X}; + bool busy_{true}; // cleared once the chip is configured after the reset + bool interrupt_output_{false}; +}; + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/output/__init__.py b/esphome/components/bh1745/output/__init__.py new file mode 100644 index 0000000000..6c5da0396b --- /dev/null +++ b/esphome/components/bh1745/output/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import CONF_BH1745_ID, BH1745Component, bh1745_ns + +DEPENDENCIES = ["bh1745"] + +BH1745InterruptPinOutput = bh1745_ns.class_( + "BH1745InterruptPinOutput", output.BinaryOutput +) + +CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(BH1745InterruptPinOutput), + cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component), + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await output.register_output(var, config) + hub = await cg.get_variable(config[CONF_BH1745_ID]) + await cg.register_parented(var, hub) + cg.add(hub.enable_interrupt_output()) diff --git a/esphome/components/bh1745/output/bh1745_output.h b/esphome/components/bh1745/output/bh1745_output.h new file mode 100644 index 0000000000..c6f30304bd --- /dev/null +++ b/esphome/components/bh1745/output/bh1745_output.h @@ -0,0 +1,16 @@ +#pragma once + +#include "esphome/components/output/binary_output.h" +#include "esphome/core/helpers.h" + +#include "../bh1745.h" + +namespace esphome::bh1745 { + +/// Drives the BH1745 INT pin, which switches the LEDs on breakout boards such as the Pimoroni one. +class BH1745InterruptPinOutput : public output::BinaryOutput, public Parented { + protected: + void write_state(bool state) override { this->parent_->set_interrupt_state(state); } +}; + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/sensor.py b/esphome/components/bh1745/sensor.py new file mode 100644 index 0000000000..6c90446625 --- /dev/null +++ b/esphome/components/bh1745/sensor.py @@ -0,0 +1,66 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_CLEAR_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, + UNIT_COUNTS, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_COLOR_TEMPERATURE, + CONF_ILLUMINANCE, + DEVICE_CLASS_ILLUMINANCE, + ICON_LIGHTBULB, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_KELVIN, + UNIT_LUX, +) +from esphome.types import ConfigType + +from . import CONF_BH1745_ID, BH1745Component + +DEPENDENCIES = ["bh1745"] + +color_channel_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_LIGHTBULB, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component), + cv.Optional(CONF_RED_CHANNEL): color_channel_schema, + cv.Optional(CONF_GREEN_CHANNEL): color_channel_schema, + cv.Optional(CONF_BLUE_CHANNEL): color_channel_schema, + cv.Optional(CONF_CLEAR_CHANNEL): color_channel_schema, + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + icon=ICON_LIGHTBULB, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_KELVIN, + icon=ICON_THERMOMETER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BH1745_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RED_CHANNEL, hub.set_red_counts_sensor) + await sensors(CONF_GREEN_CHANNEL, hub.set_green_counts_sensor) + await sensors(CONF_BLUE_CHANNEL, hub.set_blue_counts_sensor) + await sensors(CONF_CLEAR_CHANNEL, hub.set_clear_counts_sensor) + await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor) + await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor) diff --git a/tests/components/bh1745/common.yaml b/tests/components/bh1745/common.yaml new file mode 100644 index 0000000000..bad906653a --- /dev/null +++ b/tests/components/bh1745/common.yaml @@ -0,0 +1,27 @@ +bh1745: + id: bh1745_hub + i2c_id: i2c_bus + gain: 16X + integration_time: 320ms + glass_attenuation_factor: 2.0 + +sensor: + - platform: bh1745 + bh1745_id: bh1745_hub + red_channel: + name: Red channel + green_channel: + name: Green channel + blue_channel: + name: Blue channel + clear_channel: + name: Clear channel + illuminance: + name: Illuminance + color_temperature: + name: Color temperature + +output: + - platform: bh1745 + id: bh1745_output + bh1745_id: bh1745_hub diff --git a/tests/components/bh1745/test.esp32-idf.yaml b/tests/components/bh1745/test.esp32-idf.yaml new file mode 100644 index 0000000000..f7aff8bedd --- /dev/null +++ b/tests/components/bh1745/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bh1745: !include common.yaml diff --git a/tests/components/bh1745/test.esp8266-ard.yaml b/tests/components/bh1745/test.esp8266-ard.yaml new file mode 100644 index 0000000000..7132464c43 --- /dev/null +++ b/tests/components/bh1745/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bh1745: !include common.yaml From 93183c661ec4cbafa37a0eefdf059fb42fd3f155 Mon Sep 17 00:00:00 2001 From: gledian Date: Mon, 12 Oct 2026 23:36:27 +0200 Subject: [PATCH 212/268] [ecocomfort2] Add component for Fantini Cosmi VMC units via BLE (Part 1: hub + fan) (#17092) Co-authored-by: gledian Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ecocomfort2/__init__.py | 72 ++++++++ .../ecocomfort2/ecocomfort2_child.h | 20 +++ .../ecocomfort2/ecocomfort2_fan.cpp | 146 +++++++++++++++++ .../components/ecocomfort2/ecocomfort2_fan.h | 26 +++ .../ecocomfort2/ecocomfort2_hub.cpp | 154 ++++++++++++++++++ .../components/ecocomfort2/ecocomfort2_hub.h | 76 +++++++++ esphome/components/ecocomfort2/fan.py | 23 +++ esphome/core/defines.h | 1 + tests/components/ecocomfort2/common.yaml | 24 +++ .../ecocomfort2/test.esp32-idf.yaml | 3 + 11 files changed, 546 insertions(+) create mode 100644 esphome/components/ecocomfort2/__init__.py create mode 100644 esphome/components/ecocomfort2/ecocomfort2_child.h create mode 100644 esphome/components/ecocomfort2/ecocomfort2_fan.cpp create mode 100644 esphome/components/ecocomfort2/ecocomfort2_fan.h create mode 100644 esphome/components/ecocomfort2/ecocomfort2_hub.cpp create mode 100644 esphome/components/ecocomfort2/ecocomfort2_hub.h create mode 100644 esphome/components/ecocomfort2/fan.py create mode 100644 tests/components/ecocomfort2/common.yaml create mode 100644 tests/components/ecocomfort2/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2d3f8af71d..9de8399665 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -160,6 +160,7 @@ esphome/components/ds248x/* @tomwellnitz esphome/components/ds3231/* @linkedupbits esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov +esphome/components/ecocomfort2/* @gledian esphome/components/ee895/* @Stock-M esphome/components/ektf2232/touchscreen/* @jesserockz esphome/components/emc2101/* @ellull diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py new file mode 100644 index 0000000000..472ac8f7cf --- /dev/null +++ b/esphome/components/ecocomfort2/__init__.py @@ -0,0 +1,72 @@ +import esphome.codegen as cg +from esphome.components import ble_client, esp32_ble +from esphome.components.esp32_ble import ( + CONF_AUTH_REQ_MODE, + CONF_IO_CAPABILITY, + CONF_MAX_KEY_SIZE, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.cpp_generator import MockObj +import esphome.final_validate as fv +from esphome.types import ConfigType + +DOMAIN = "ecocomfort2" +CONF_ECOCOMFORT2_ID = "ecocomfort2_id" + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ble_client"] +MULTI_CONF = True + +ecocomfort2_ns = cg.esphome_ns.namespace(DOMAIN) +Ecocomfort2Hub = ecocomfort2_ns.class_( + "Ecocomfort2Hub", ble_client.BLEClientNode, cg.PollingComponent +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Ecocomfort2Hub), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("30s")) +) + +ECOCOMFORT2_CLIENT_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ECOCOMFORT2_ID): cv.use_id(Ecocomfort2Hub), + } +) + +_request_child_slot = cg.slot_counter("ECOCOMFORT2_CHILD_COUNT") + + +async def register_ecocomfort2_child(var: MockObj, config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + _request_child_slot(str(parent)) + cg.add(parent.register_child(var)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await ble_client.register_ble_node(var, config) + + +def _final_validate(config: ConfigType) -> ConfigType: + # The unit only talks over an encrypted, bonded link + ble_conf = fv.full_config.get()[esp32_ble.DOMAIN] + if ( + ble_conf[CONF_IO_CAPABILITY] != "none" + or ble_conf.get(CONF_AUTH_REQ_MODE) != "sc_bond" + or ble_conf.get(CONF_MAX_KEY_SIZE) != 16 + ): + raise cv.Invalid( + "ecocomfort2 needs esp32_ble configured with io_capability: none, " + "auth_req_mode: sc_bond and max_key_size: 16" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ecocomfort2/ecocomfort2_child.h b/esphome/components/ecocomfort2/ecocomfort2_child.h new file mode 100644 index 0000000000..b6d2fc87cb --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_child.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/core/helpers.h" + +namespace esphome::ecocomfort2 { + +class Ecocomfort2Hub; + +class Ecocomfort2Client : public Parented { + public: + /// Called after each successful C_OPER readback. + virtual void on_status() = 0; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp new file mode 100644 index 0000000000..c9d64892c4 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -0,0 +1,146 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_fan.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ecocomfort2 { + +ESPHOME_LOG_TAG(TAG, "ecocomfort2.fan"); + +static constexpr int SPEED_COUNT = SPEED_VEL3; + +struct Preset { + const char *name; + uint8_t mode; + bool auto_mode; +}; + +// Order matters: the fan core persists the preset as an index into this list. +static constexpr Preset PRESETS[] = { + {"In", OPER_IN, false}, + {"Out", OPER_OUT, false}, + {"In-Out", OPER_IN_OUT, false}, + {"Sensor", OPER_SENSOR_OR_AUTO, false}, + {"Auto", OPER_SENSOR_OR_AUTO, true}, +}; +static constexpr const Preset &PRESET_IN_OUT = PRESETS[2]; +static constexpr const Preset &PRESET_SENSOR = PRESETS[3]; + +static const Preset *find_preset(const char *name) { + for (const auto &preset : PRESETS) { + if (preset.name == name) + return &preset; + } + return nullptr; +} + +static const Preset *find_preset(uint8_t mode, bool auto_mode) { + for (const auto &preset : PRESETS) { + if (preset.mode == mode && preset.auto_mode == auto_mode) + return &preset; + } + return nullptr; +} + +void Ecocomfort2Fan::setup() { + this->set_supported_preset_modes( + {PRESETS[0].name, PRESETS[1].name, PRESETS[2].name, PRESETS[3].name, PRESETS[4].name}); + // Restore the last preset and speed so turning on resumes them; on/off comes from the device. + auto restore = this->restore_state_(); + if (restore.has_value()) { + this->speed = restore->speed; + if (restore->preset_mode < std::size(PRESETS)) + this->set_preset_mode_(PRESETS[restore->preset_mode].name); + } +} + +void Ecocomfort2Fan::dump_config() { LOG_FAN("", "Ecocomfort2 Fan", this); } + +fan::FanTraits Ecocomfort2Fan::get_traits() { + fan::FanTraits traits; + traits.set_speed(true); + traits.set_supported_speed_count(SPEED_COUNT); + this->wire_preset_modes_(traits); + return traits; +} + +void Ecocomfort2Fan::control(const fan::FanCall &call) { + if (!this->parent_->is_ready()) { + ESP_LOGW(TAG, "Not ready, cannot handle control call"); + return; + } + + // Any call that does not turn the fan off turns it on + auto state = call.get_state(); + this->state = !state.has_value() || *state; + // The core clears the preset in a speed call; here the preset is the air flow mode, so keep it. + if (call.has_preset_mode()) + this->set_preset_mode_(call.get_preset_mode()); + + const Preset *preset = find_preset(this->get_preset_mode().c_str()); + if (preset == nullptr) { + preset = &PRESET_IN_OUT; + this->set_preset_mode_(preset->name); + } + auto speed = call.get_speed(); + if (speed.has_value()) { + this->speed = *speed; + // A manual speed leaves automatic speed control + if (preset->auto_mode) { + preset = &PRESET_SENSOR; + this->set_preset_mode_(preset->name); + } + } + this->speed = clamp(this->speed, 1, SPEED_COUNT); + + if (!this->state) { + this->parent_->write_oper(OPER_OFF, SPEED_OFF); + } else { + this->parent_->write_oper(preset->mode, preset->auto_mode ? FLAG_AUTO : static_cast(this->speed)); + } + this->publish_state(); +} + +void Ecocomfort2Fan::on_status() { + uint8_t mode = this->parent_->get_mode(); + uint8_t raw_speed = this->parent_->get_speed(); + + // Off keeps the preset and speed so turning on resumes them + if (mode == OPER_OFF) { + if (!this->state) + return; + this->state = false; + this->publish_state(); + return; + } + + int new_speed = this->speed; + uint8_t base_speed = raw_speed & SPEED_MASK; + if (raw_speed & FLAG_BOOST) { + new_speed = SPEED_COUNT; + } else if (raw_speed & FLAG_NIGHT) { + new_speed = SPEED_SLEEP; + } else if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) { + new_speed = base_speed; + } + const Preset *preset = find_preset(mode, (raw_speed & FLAG_AUTO) != 0); + const char *new_preset = preset != nullptr ? preset->name : nullptr; + bool same_preset = new_preset == nullptr ? !this->has_preset_mode() : this->get_preset_mode().c_str() == new_preset; + if (this->state && this->speed == new_speed && same_preset) + return; + + this->state = true; + this->speed = new_speed; + if (new_preset != nullptr) { + this->set_preset_mode_(new_preset); + } else { + this->clear_preset_mode_(); + } + this->publish_state(); +} + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.h b/esphome/components/ecocomfort2/ecocomfort2_fan.h new file mode 100644 index 0000000000..81af55699f --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/fan/fan.h" +#include "ecocomfort2_child.h" +#include "ecocomfort2_hub.h" + +namespace esphome::ecocomfort2 { + +class Ecocomfort2Fan : public fan::Fan, public Ecocomfort2Client, public Component { + public: + void setup() override; + void dump_config() override; + fan::FanTraits get_traits() override; + void on_status() override; + + protected: + void control(const fan::FanCall &call) override; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp new file mode 100644 index 0000000000..a20fdd9112 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -0,0 +1,154 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_hub.h" +#include "esphome/core/log.h" + +namespace esphome::ecocomfort2 { + +ESPHOME_LOG_TAG(TAG, "ecocomfort2"); + +static const espbt::ESPBTUUID SERVICE_UUID = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d"); +static const espbt::ESPBTUUID C_OPER_UUID = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1"); + +void Ecocomfort2Hub::update() { + if (!this->is_ready()) { + ESP_LOGD(TAG, "[%s] Not ready, skipping update", this->parent_->address_str()); + return; + } + auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), + this->char_handle_oper_, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER read request failed, status=%d", this->parent_->address_str(), status); + } +} + +void Ecocomfort2Hub::dump_config() { + ESP_LOGCONFIG(TAG, + "Ecocomfort2 Hub:\n" + " Address: %s", + this->parent_->address_str()); + LOG_UPDATE_INTERVAL(this); +} + +void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + switch (event) { + case ESP_GATTC_DISCONNECT_EVT: { + ESP_LOGD(TAG, "[%s] Disconnected: reason=%d", this->parent_->address_str(), param->disconnect.reason); + this->cancel_timeout(AUTH_TIMEOUT_ID); + this->cancel_timeout(READBACK_TIMEOUT_ID); + this->status_set_warning(); + this->char_handle_oper_ = 0; + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + // Keep the handle, not the characteristic: ble_client frees its services once all nodes are established. + auto *chr = this->parent_->get_characteristic(SERVICE_UUID, C_OPER_UUID); + if (chr == nullptr) { + ESP_LOGW(TAG, "[%s] No C_OPER characteristic found, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + this->char_handle_oper_ = chr->handle; + this->node_state = espbt::ClientState::ESTABLISHED; + this->status_set_warning(); + + // A bonded device may already have re-encrypted the link before discovery finished. + if (this->parent_->is_paired()) { + this->on_paired_(); + break; + } + ESP_LOGI(TAG, "[%s] Requesting BLE encryption", this->parent_->address_str()); + if (this->parent_->pair() != ESP_OK) { + ESP_LOGW(TAG, "[%s] BLE pair request failed, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + this->set_timeout(AUTH_TIMEOUT_ID, AUTH_TIMEOUT_MS, [this]() { + ESP_LOGW(TAG, "[%s] BLE authentication timed out, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + }); + break; + } + case ESP_GATTC_READ_CHAR_EVT: { + if (param->read.conn_id != this->parent_->get_conn_id() || param->read.handle != this->char_handle_oper_) { + break; + } + if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) { + // ble_client reconnects and pairs again + ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER read failed, status=%d", this->parent_->address_str(), param->read.status); + break; + } + if (param->read.value_len < 2) { + ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), param->read.value_len); + break; + } + this->mode_ = param->read.value[0]; + this->speed_ = param->read.value[1]; + ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); +#ifdef ECOCOMFORT2_CHILD_COUNT + for (auto *child : this->children_) { + child->on_status(); + } +#endif + break; + } + default: + break; + } +} + +void Ecocomfort2Hub::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + if (event != ESP_GAP_BLE_AUTH_CMPL_EVT || !this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr) || + this->node_state != espbt::ClientState::ESTABLISHED) { + return; + } + if (!param->ble_security.auth_cmpl.success) { + this->cancel_timeout(AUTH_TIMEOUT_ID); + ESP_LOGW(TAG, "[%s] BLE authentication failed, reason=%d, disconnecting", this->parent_->address_str(), + param->ble_security.auth_cmpl.fail_reason); + this->parent_->disconnect(); + return; + } + this->on_paired_(); +} + +void Ecocomfort2Hub::on_paired_() { + this->cancel_timeout(AUTH_TIMEOUT_ID); + this->status_clear_warning(); + ESP_LOGI(TAG, "[%s] Encryption confirmed, device ready", this->parent_->address_str()); + this->update(); +} + +void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) { + if (!this->is_ready()) { + ESP_LOGW(TAG, "[%s] Not ready, cannot write C_OPER", this->parent_->address_str()); + return; + } + uint8_t data[2]{mode, speed}; + ESP_LOGD(TAG, "[%s] Writing C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), mode, speed); + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_oper_, + sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER write failed, status=%d", this->parent_->address_str(), status); + return; + } + this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->update(); }); +} + +#ifdef ECOCOMFORT2_CHILD_COUNT +void Ecocomfort2Hub::register_child(Ecocomfort2Client *obj) { + this->children_.push_back(obj); + obj->set_parent(this); +} +#endif + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h new file mode 100644 index 0000000000..5a609d9283 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "ecocomfort2_child.h" + +namespace esphome::ecocomfort2 { + +namespace espbt = esphome::esp32_ble_tracker; + +// C_OPER byte 0: operating mode +static constexpr uint8_t OPER_OFF = 0x00; +static constexpr uint8_t OPER_IN = 0x01; +static constexpr uint8_t OPER_OUT = 0x02; +static constexpr uint8_t OPER_IN_OUT = 0x03; +static constexpr uint8_t OPER_SENSOR_OR_AUTO = 0x04; + +// C_OPER byte 1: speed in the low nibble, flags in the high nibble +static constexpr uint8_t SPEED_OFF = 0x00; +static constexpr uint8_t SPEED_SLEEP = 0x01; +static constexpr uint8_t SPEED_VEL1 = 0x02; +static constexpr uint8_t SPEED_VEL2 = 0x03; +static constexpr uint8_t SPEED_VEL3 = 0x04; +static constexpr uint8_t SPEED_MASK = 0x0F; +static constexpr uint8_t FLAG_AUTO = 0x10; +static constexpr uint8_t FLAG_BOOST = 0x40; +static constexpr uint8_t FLAG_NIGHT = 0x80; + +/** Hub component for Ecocomfort 2.0 VMC via BLE. */ +class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent { + public: + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BLUETOOTH; } + + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + +#ifdef ECOCOMFORT2_CHILD_COUNT + void register_child(Ecocomfort2Client *obj); +#endif + + /// True once the link is established and paired. + bool is_ready() const { return this->node_state == espbt::ClientState::ESTABLISHED && this->parent_->is_paired(); } + + /// Write C_OPER (mode byte, speed/flags byte) and read it back shortly after. + void write_oper(uint8_t mode, uint8_t speed); + + /// Raw C_OPER values from the last readback. + uint8_t get_mode() const { return this->mode_; } + uint8_t get_speed() const { return this->speed_; } + + protected: + static constexpr uint32_t AUTH_TIMEOUT_ID = 0; + static constexpr uint32_t READBACK_TIMEOUT_ID = 1; + static constexpr uint32_t AUTH_TIMEOUT_MS = 10000; + static constexpr uint32_t READBACK_DELAY_MS = 300; + + void on_paired_(); + +#ifdef ECOCOMFORT2_CHILD_COUNT + StaticVector children_; +#endif + uint16_t char_handle_oper_{0}; + uint8_t mode_{OPER_OFF}; + uint8_t speed_{SPEED_OFF}; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/fan.py b/esphome/components/ecocomfort2/fan.py new file mode 100644 index 0000000000..e3dd8e0938 --- /dev/null +++ b/esphome/components/ecocomfort2/fan.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import fan +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import ECOCOMFORT2_CLIENT_SCHEMA, ecocomfort2_ns, register_ecocomfort2_child + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +Ecocomfort2Fan = ecocomfort2_ns.class_("Ecocomfort2Fan", fan.Fan, cg.Component) + +CONFIG_SCHEMA = ( + fan.fan_schema(Ecocomfort2Fan) + .extend(cv.COMPONENT_SCHEMA) + .extend(ECOCOMFORT2_CLIENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = await fan.new_fan(config) + await cg.register_component(var, config) + await register_ecocomfort2_child(var, config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 943458e3f6..a085cf9c0a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -430,6 +430,7 @@ #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 +#define ECOCOMFORT2_CHILD_COUNT 1 #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS #define ESPHOME_LOOP_TASK_STACK_SIZE 8192 diff --git a/tests/components/ecocomfort2/common.yaml b/tests/components/ecocomfort2/common.yaml new file mode 100644 index 0000000000..b4951e804f --- /dev/null +++ b/tests/components/ecocomfort2/common.yaml @@ -0,0 +1,24 @@ +esp32_ble: + io_capability: none + auth_req_mode: sc_bond + max_key_size: 16 + +ble_client: + - mac_address: 01:02:03:04:05:06 + id: ecocomfort2_ble_1 + - mac_address: 01:02:03:04:05:07 + id: ecocomfort2_ble_2 + +ecocomfort2: + - id: ecocomfort2_hub_1 + ble_client_id: ecocomfort2_ble_1 + - id: ecocomfort2_hub_2 + ble_client_id: ecocomfort2_ble_2 + +fan: + - platform: ecocomfort2 + name: Ecocomfort2 Fan 1 + ecocomfort2_id: ecocomfort2_hub_1 + - platform: ecocomfort2 + name: Ecocomfort2 Fan 2 + ecocomfort2_id: ecocomfort2_hub_2 diff --git a/tests/components/ecocomfort2/test.esp32-idf.yaml b/tests/components/ecocomfort2/test.esp32-idf.yaml new file mode 100644 index 0000000000..956cd9c813 --- /dev/null +++ b/tests/components/ecocomfort2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + ecocomfort2: !include common.yaml From 584d4878ebafc8807ab56d7752d241dcc282b709 Mon Sep 17 00:00:00 2001 From: Zebble Date: Mon, 12 Oct 2026 17:44:33 -0400 Subject: [PATCH 213/268] [lis3dh] Add LIS3DH accelerometer component (#17393) Co-authored-by: Claude Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/lis3dh/__init__.py | 15 ++ esphome/components/lis3dh/lis3dh.cpp | 195 ++++++++++++++++ esphome/components/lis3dh/lis3dh.h | 135 +++++++++++ esphome/components/lis3dh/motion.py | 214 ++++++++++++++++++ esphome/components/lis3dh/sensor.py | 41 ++++ tests/component_tests/lis3dh/__init__.py | 0 tests/component_tests/lis3dh/test_motion.py | 147 ++++++++++++ tests/components/lis3dh/common.yaml | 67 ++++++ tests/components/lis3dh/test.esp32-idf.yaml | 3 + tests/components/lis3dh/test.esp8266-ard.yaml | 3 + 11 files changed, 821 insertions(+) create mode 100644 esphome/components/lis3dh/__init__.py create mode 100644 esphome/components/lis3dh/lis3dh.cpp create mode 100644 esphome/components/lis3dh/lis3dh.h create mode 100644 esphome/components/lis3dh/motion.py create mode 100644 esphome/components/lis3dh/sensor.py create mode 100644 tests/component_tests/lis3dh/__init__.py create mode 100644 tests/component_tests/lis3dh/test_motion.py create mode 100644 tests/components/lis3dh/common.yaml create mode 100644 tests/components/lis3dh/test.esp32-idf.yaml create mode 100644 tests/components/lis3dh/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9de8399665..8f1271b76a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -320,6 +320,7 @@ esphome/components/lilygo_t5_47/touchscreen/* @jesserockz esphome/components/lis2dw12/* @latonita esphome/components/lis2dw12_i2c/* @latonita esphome/components/lis2dw12_spi/* @latonita +esphome/components/lis3dh/* @zebble esphome/components/lm75b/* @beormund esphome/components/ln882h_ble/* @Bl00d-B0b esphome/components/ln882h_ble_tracker/* @Bl00d-B0b diff --git a/esphome/components/lis3dh/__init__.py b/esphome/components/lis3dh/__init__.py new file mode 100644 index 0000000000..99c4ee9187 --- /dev/null +++ b/esphome/components/lis3dh/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@zebble"] +DEPENDENCIES = ["i2c", "motion"] +DOMAIN = "lis3dh" + +CONF_LIS3DH_ID = "lis3dh_id" + +# C++ namespace / class +lis3dh_ns = cg.esphome_ns.namespace("lis3dh") +LIS3DHComponent = lis3dh_ns.class_("LIS3DHComponent", MotionComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = {} diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp new file mode 100644 index 0000000000..22ca7ae56b --- /dev/null +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -0,0 +1,195 @@ +#include "lis3dh.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lis3dh { + +ESPHOME_LOG_TAG(TAG, "lis3dh"); + +// g per LSB of the left-justified 16-bit sample, indexed by range. The same in every +// operating mode, since the chip leaves the unused low bits at zero. +static const float SCALE_G[] PROGMEM = {0.0625e-3f, 0.125e-3f, 0.25e-3f, 0.75e-3f}; + +static const LogString *mode_to_str(LIS3DHOperatingMode mode) { + switch (mode) { + case LIS3DH_MODE_LOW_POWER: + return LOG_STR("low power (8-bit)"); + case LIS3DH_MODE_NORMAL: + return LOG_STR("normal (10-bit)"); + default: + return LOG_STR("high resolution (12-bit)"); + } +} + +// Output data rate in Hz, indexed by the ODR field minus one. +static const uint16_t RATE_HZ[] PROGMEM = {1, 10, 25, 50, 100, 200, 400, 1620, 1344}; + +void LIS3DHComponent::setup() { + MotionComponent::setup(); + + uint8_t who_am_i = 0; + if (!this->read_byte(LIS3DH_REG_WHO_AM_I, &who_am_i)) { + ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); + this->mark_failed(); + return; + } + if (who_am_i != LIS3DH_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, LIS3DH_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { + ESP_LOGE(TAG, "Failed to configure sensor"); + this->mark_failed(); + return; + } +} + +bool LIS3DHComponent::setup_accelerometer_() { + // CTRL_REG1: output data rate, low-power flag and axis enable. + uint8_t ctrl_reg1 = ((uint8_t) this->data_rate_ << 4) | LIS3DH_CTRL_REG1_AXES_EN; + if (this->operating_mode_ == LIS3DH_MODE_LOW_POWER) + ctrl_reg1 |= LIS3DH_CTRL_REG1_LPEN; + if (!this->write_byte(LIS3DH_REG_CTRL_REG1, ctrl_reg1)) + return false; + + // CTRL_REG4: block data update, full-scale range and high-resolution flag. + uint8_t ctrl_reg4 = LIS3DH_CTRL_REG4_BDU | ((uint8_t) this->range_ << 4); + if (this->operating_mode_ == LIS3DH_MODE_HIGH_RESOLUTION) + ctrl_reg4 |= LIS3DH_CTRL_REG4_HR; + return this->write_byte(LIS3DH_REG_CTRL_REG4, ctrl_reg4); +} + +bool LIS3DHComponent::setup_temperature_() { + // Only power the ADC / temperature sensor when a temperature sensor is used. + uint8_t temp_cfg = + this->temperature_callback_.empty() ? 0x00 : (uint8_t) (LIS3DH_TEMP_CFG_ADC_EN | LIS3DH_TEMP_CFG_TEMP_EN); + return this->write_byte(LIS3DH_REG_TEMP_CFG, temp_cfg); +} + +bool LIS3DHComponent::setup_interrupt_() { + // Runs even with no interrupt configured: the chip usually stays powered across resets, + // so this drops whatever the previous firmware left in the interrupt registers. + // High-pass filter the interrupt (IA1) input so the ~1 g of gravity is removed + // from the threshold comparison. The output data registers stay unfiltered + // (FDS = 0), so acceleration / pitch / roll still report the full gravity vector. + uint8_t ctrl_reg2 = this->interrupt_high_pass_ ? LIS3DH_CTRL_REG2_HP_IA1 : 0x00; + if (!this->write_byte(LIS3DH_REG_CTRL_REG2, ctrl_reg2)) + return false; + + if (!this->write_byte(LIS3DH_REG_INT1_CFG, this->interrupt_axes_cfg_) || + !this->write_byte(LIS3DH_REG_INT1_THS, this->interrupt_threshold_) || + !this->write_byte(LIS3DH_REG_INT1_DURATION, this->interrupt_duration_)) + return false; + + // Latch the request so a brief movement keeps the pad asserted until INT1_SRC is read. + uint8_t ctrl_reg5 = this->interrupt_latched_ ? LIS3DH_CTRL_REG5_LIR_INT1 : 0; + if (!this->write_byte(LIS3DH_REG_CTRL_REG5, ctrl_reg5)) + return false; + + // Route the IA1 engine to the chosen physical pad and set the pad polarity. + uint8_t ctrl_reg3 = 0; + uint8_t ctrl_reg6 = this->interrupt_active_high_ ? 0 : LIS3DH_CTRL_REG6_INT_POLARITY; + if (this->interrupt_axes_cfg_ != 0) { + if (this->interrupt_pin_ == LIS3DH_INT_PIN_INT1) { + ctrl_reg3 |= LIS3DH_CTRL_REG3_I1_IA1; + } else { + ctrl_reg6 |= LIS3DH_CTRL_REG6_I2_IA1; + } + } + if (!this->write_byte(LIS3DH_REG_CTRL_REG3, ctrl_reg3) || !this->write_byte(LIS3DH_REG_CTRL_REG6, ctrl_reg6)) + return false; + if (this->interrupt_axes_cfg_ == 0) + return true; + + // With the high-pass filter enabled, reading REFERENCE captures the current + // acceleration as the filter's zero point (removing the standing gravity bias). + if (this->interrupt_high_pass_) { + uint8_t reference = 0; + this->read_byte(LIS3DH_REG_REFERENCE, &reference); + } + + // Clear any latched request left over from the motion that woke the device. + uint8_t src = 0; + this->read_byte(LIS3DH_REG_INT1_SRC, &src); + return true; +} + +void LIS3DHComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LIS3DH:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + + ESP_LOGCONFIG(TAG, + " Range: ±%ug\n" + " Operating mode: %s\n" + " Data rate: %uHz", + 2u << this->range_, LOG_STR_ARG(mode_to_str(this->operating_mode_)), + (unsigned) progmem_read_uint16(&RATE_HZ[this->data_rate_ - 1])); + if (this->interrupt_axes_cfg_ != 0) { + ESP_LOGCONFIG(TAG, + " Motion interrupt: %s (%s%s%s)\n" + " Threshold: %u steps\n" + " Duration: %u samples", + this->interrupt_pin_ == LIS3DH_INT_PIN_INT1 ? LOG_STR_LITERAL("INT1") : LOG_STR_LITERAL("INT2"), + this->interrupt_active_high_ ? LOG_STR_LITERAL("active high") : LOG_STR_LITERAL("active low"), + this->interrupt_high_pass_ ? LOG_STR_LITERAL(", high-pass") : LOG_STR_LITERAL(""), + this->interrupt_latched_ ? LOG_STR_LITERAL(", latched") : LOG_STR_LITERAL(""), + this->interrupt_threshold_, this->interrupt_duration_); + } + MotionComponent::dump_config(); +} + +void LIS3DHComponent::on_shutdown() { + // Clear a latched request from motion seen while awake, so it does not wake the device straight away. + if (this->interrupt_latched_ && this->interrupt_axes_cfg_ != 0 && !this->is_failed()) { + uint8_t src = 0; + this->read_byte(LIS3DH_REG_INT1_SRC, &src); + } +} + +bool LIS3DHComponent::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + uint8_t raw[6]; + if (!this->read_bytes(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 6)) { + this->status_set_warning(LOG_STR("Failed to read acceleration data")); + return false; + } + + float scale; + progmem_memcpy(&scale, &SCALE_G[this->range_], sizeof(scale)); + + // Data is little-endian and left-justified in a signed 16-bit register. + int16_t raw_x = (int16_t) encode_uint16(raw[1], raw[0]); + int16_t raw_y = (int16_t) encode_uint16(raw[3], raw[2]); + int16_t raw_z = (int16_t) encode_uint16(raw[5], raw[4]); + ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); + data.acceleration[motion::X_AXIS] = raw_x * scale; + data.acceleration[motion::Y_AXIS] = raw_y * scale; + data.acceleration[motion::Z_AXIS] = raw_z * scale; + + if (this->temperature_callback_.empty() || this->publish_temperature_()) + this->status_clear_warning(); + return true; +} + +bool LIS3DHComponent::publish_temperature_() { + uint8_t raw[2]; + if (!this->read_bytes(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, raw, 2)) { + this->status_set_warning(LOG_STR("Failed to read temperature data")); + return false; + } + // Signed, left-justified, relative value with 1 °C per count of the high byte; the + // low byte carries the fractional bits in the modes that have them. + float temperature = (int16_t) encode_uint16(raw[1], raw[0]) / 256.0f + LIS3DH_TEMP_REFERENCE_C; + this->temperature_callback_.call(temperature); + return true; +} + +} // namespace esphome::lis3dh diff --git a/esphome/components/lis3dh/lis3dh.h b/esphome/components/lis3dh/lis3dh.h new file mode 100644 index 0000000000..15c19933c7 --- /dev/null +++ b/esphome/components/lis3dh/lis3dh.h @@ -0,0 +1,135 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::lis3dh { + +// Register map (see ST datasheet DocID18198) +static constexpr uint8_t LIS3DH_REG_OUT_ADC3_L = 0x0C; // temperature (ADC3) low byte +static constexpr uint8_t LIS3DH_REG_WHO_AM_I = 0x0F; +static constexpr uint8_t LIS3DH_REG_TEMP_CFG = 0x1F; +static constexpr uint8_t LIS3DH_REG_CTRL_REG1 = 0x20; // ODR / low-power / axis enable +static constexpr uint8_t LIS3DH_REG_CTRL_REG2 = 0x21; // high-pass filter +static constexpr uint8_t LIS3DH_REG_CTRL_REG3 = 0x22; // INT1 pad routing +static constexpr uint8_t LIS3DH_REG_CTRL_REG4 = 0x23; // BDU / full-scale / high-resolution +static constexpr uint8_t LIS3DH_REG_CTRL_REG5 = 0x24; // interrupt latching +static constexpr uint8_t LIS3DH_REG_CTRL_REG6 = 0x25; // INT2 pad routing / polarity +static constexpr uint8_t LIS3DH_REG_REFERENCE = 0x26; // high-pass filter reference +static constexpr uint8_t LIS3DH_REG_OUT_X_L = 0x28; // acceleration data block (auto-increment) +static constexpr uint8_t LIS3DH_REG_INT1_CFG = 0x30; +static constexpr uint8_t LIS3DH_REG_INT1_SRC = 0x31; +static constexpr uint8_t LIS3DH_REG_INT1_THS = 0x32; +static constexpr uint8_t LIS3DH_REG_INT1_DURATION = 0x33; + +static constexpr uint8_t LIS3DH_WHO_AM_I_VALUE = 0x33; + +// Sub-address auto-increment bit: OR into the register for multi-byte reads. +static constexpr uint8_t LIS3DH_AUTO_INCREMENT = 0x80; + +// CTRL_REG1 +static constexpr uint8_t LIS3DH_CTRL_REG1_AXES_EN = 0x07; // Xen | Yen | Zen +static constexpr uint8_t LIS3DH_CTRL_REG1_LPEN = 0x08; // low-power enable +// CTRL_REG2 +static constexpr uint8_t LIS3DH_CTRL_REG2_HP_IA1 = 0x01; // high-pass filter the IA1 interrupt +// CTRL_REG4 +static constexpr uint8_t LIS3DH_CTRL_REG4_HR = 0x08; // high-resolution enable +static constexpr uint8_t LIS3DH_CTRL_REG4_BDU = 0x80; // block data update +// CTRL_REG5 +static constexpr uint8_t LIS3DH_CTRL_REG5_LIR_INT1 = 0x08; // latch IA1 interrupt +// CTRL_REG3 / CTRL_REG6 interrupt routing +static constexpr uint8_t LIS3DH_CTRL_REG3_I1_IA1 = 0x40; // route IA1 to INT1 pad +static constexpr uint8_t LIS3DH_CTRL_REG6_I2_IA1 = 0x40; // route IA1 to INT2 pad +static constexpr uint8_t LIS3DH_CTRL_REG6_INT_POLARITY = 0x02; // 1 = active low +// TEMP_CFG_REG +static constexpr uint8_t LIS3DH_TEMP_CFG_ADC_EN = 0x80; +static constexpr uint8_t LIS3DH_TEMP_CFG_TEMP_EN = 0x40; + +// The temperature sensor is uncalibrated and reports relative changes only, +// so this fixed reference is just a rough starting point for the °C output. +static constexpr float LIS3DH_TEMP_REFERENCE_C = 25.0f; + +// Full-scale range: value is the index into the scale tables and the +// FS field of CTRL_REG4 (shifted left by 4). +enum LIS3DHRange : uint8_t { + LIS3DH_RANGE_2G = 0, + LIS3DH_RANGE_4G = 1, + LIS3DH_RANGE_8G = 2, + LIS3DH_RANGE_16G = 3, +}; + +// Output data rate: value is the ODR field of CTRL_REG1 (bits 7:4). +enum LIS3DHDataRate : uint8_t { + LIS3DH_DATA_RATE_1HZ = 0x1, + LIS3DH_DATA_RATE_10HZ = 0x2, + LIS3DH_DATA_RATE_25HZ = 0x3, + LIS3DH_DATA_RATE_50HZ = 0x4, + LIS3DH_DATA_RATE_100HZ = 0x5, + LIS3DH_DATA_RATE_200HZ = 0x6, + LIS3DH_DATA_RATE_400HZ = 0x7, + LIS3DH_DATA_RATE_1620HZ = 0x8, // low-power mode only + LIS3DH_DATA_RATE_1344HZ = 0x9, // 1344 Hz normal/HR, 5376 Hz low-power +}; + +// Operating mode: selects the resolution (and thus power usage). +enum LIS3DHOperatingMode : uint8_t { + LIS3DH_MODE_LOW_POWER = 0, // 8-bit + LIS3DH_MODE_NORMAL = 1, // 10-bit + LIS3DH_MODE_HIGH_RESOLUTION = 2, // 12-bit +}; + +// Physical interrupt pad the motion interrupt is routed to. +enum LIS3DHInterruptPin : uint8_t { + LIS3DH_INT_PIN_INT1 = 0, + LIS3DH_INT_PIN_INT2 = 1, +}; + +class LIS3DHComponent : public motion::MotionComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void on_shutdown() override; + + void set_range(LIS3DHRange range) { this->range_ = range; } + void set_data_rate(LIS3DHDataRate data_rate) { this->data_rate_ = data_rate; } + void set_operating_mode(LIS3DHOperatingMode mode) { this->operating_mode_ = mode; } + // threshold is the INT1_THS value and axes_cfg the INT1_CFG mask, both computed in Python. + void set_interrupt(LIS3DHInterruptPin pin, uint8_t threshold, uint8_t duration, uint8_t axes_cfg, bool latched, + bool active_high, bool high_pass) { + this->interrupt_pin_ = pin; + this->interrupt_threshold_ = threshold; + this->interrupt_duration_ = duration; + this->interrupt_axes_cfg_ = axes_cfg; + this->interrupt_latched_ = latched; + this->interrupt_active_high_ = active_high; + this->interrupt_high_pass_ = high_pass; + } + + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + bool setup_accelerometer_(); + bool setup_temperature_(); + bool setup_interrupt_(); + bool publish_temperature_(); + + LIS3DHRange range_{LIS3DH_RANGE_2G}; + LIS3DHDataRate data_rate_{LIS3DH_DATA_RATE_100HZ}; + LIS3DHOperatingMode operating_mode_{LIS3DH_MODE_HIGH_RESOLUTION}; + + // Motion (activity) interrupt configuration; a zero axis mask means disabled. + LIS3DHInterruptPin interrupt_pin_{LIS3DH_INT_PIN_INT1}; + uint8_t interrupt_threshold_{0}; + uint8_t interrupt_duration_{0}; + uint8_t interrupt_axes_cfg_{0}; + bool interrupt_latched_{false}; + bool interrupt_active_high_{true}; + bool interrupt_high_pass_{false}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::lis3dh diff --git a/esphome/components/lis3dh/motion.py b/esphome/components/lis3dh/motion.py new file mode 100644 index 0000000000..ea4b4d3b81 --- /dev/null +++ b/esphome/components/lis3dh/motion.py @@ -0,0 +1,214 @@ +import logging +import math + +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import CONF_ACCELEROMETER_ODR, CONF_ACCELEROMETER_RANGE +from esphome.components.motion import AXES, motion_schema, new_motion_component +import esphome.config_validation as cv +from esphome.const import CONF_DURATION, CONF_INTERRUPT, CONF_PIN, CONF_THRESHOLD +from esphome.types import ConfigType + +from . import LIS3DHComponent, lis3dh_ns + +_LOGGER = logging.getLogger(__name__) + +CONF_OPERATING_MODE = "operating_mode" +CONF_AXES = "axes" +CONF_LATCHED = "latched" +CONF_ACTIVE_HIGH = "active_high" +CONF_HIGH_PASS_FILTER = "high_pass_filter" + +# Enum values must match the C++ enums. +LIS3DHRange = lis3dh_ns.enum("LIS3DHRange") +RANGE_OPTIONS = { + "2G": LIS3DHRange.LIS3DH_RANGE_2G, + "4G": LIS3DHRange.LIS3DH_RANGE_4G, + "8G": LIS3DHRange.LIS3DH_RANGE_8G, + "16G": LIS3DHRange.LIS3DH_RANGE_16G, +} + +LIS3DHDataRate = lis3dh_ns.enum("LIS3DHDataRate") +DATA_RATE_OPTIONS = { + "1HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1HZ, + "10HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_10HZ, + "25HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_25HZ, + "50HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_50HZ, + "100HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_100HZ, + "200HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_200HZ, + "400HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_400HZ, + # 1620HZ is only available in low-power mode. + "1620HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1620HZ, + # 1344HZ in normal / high-resolution mode; 5376HZ in low-power mode. + "1344HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1344HZ, +} + +# Samples per second for each data rate key; INT1_DURATION counts samples. +ODR_HZ = { + "1HZ": 1, + "10HZ": 10, + "25HZ": 25, + "50HZ": 50, + "100HZ": 100, + "200HZ": 200, + "400HZ": 400, + "1620HZ": 1620, + "1344HZ": 1344, +} +DURATION_MAX_REG = 0x7F + +LIS3DHOperatingMode = lis3dh_ns.enum("LIS3DHOperatingMode") +OPERATING_MODE_OPTIONS = { + "LOW_POWER": LIS3DHOperatingMode.LIS3DH_MODE_LOW_POWER, + "NORMAL": LIS3DHOperatingMode.LIS3DH_MODE_NORMAL, + "HIGH_RESOLUTION": LIS3DHOperatingMode.LIS3DH_MODE_HIGH_RESOLUTION, +} + +LIS3DHInterruptPin = lis3dh_ns.enum("LIS3DHInterruptPin") +INTERRUPT_PIN_OPTIONS = { + "INT1": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT1, + "INT2": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT2, +} + +# INT1_CFG high-event bit per axis. +INT_CFG_HIGH_EVENT = {"x": 0x02, "y": 0x08, "z": 0x20} + +# Motion interrupt threshold LSB in mg, per range. INT1_THS holds seven bits. +THRESHOLD_LSB_MG = {"2G": 16, "4G": 32, "8G": 62, "16G": 186} +THRESHOLD_MAX_REG = 0x7F + + +def _threshold_register(threshold_g: float, range_key: str) -> int: + """Return INT1_THS for a threshold in g: nearest step, at least 1 so the pad is not held asserted.""" + return max(math.floor(threshold_g * 1000 / THRESHOLD_LSB_MG[range_key] + 0.5), 1) + + +def _duration_register(duration_ms: int, odr_key: str) -> int: + """Return INT1_DURATION for a time in ms: the nearest number of samples at the data rate.""" + return math.floor(duration_ms * ODR_HZ[odr_key] / 1000 + 0.5) + + +def _axes(value: object) -> list[str]: + value = cv.All(cv.ensure_list(cv.one_of(*AXES, lower=True)), cv.Length(min=1))( + value + ) + if len(set(value)) != len(value): + raise cv.Invalid("Each axis may be listed only once") + return value + + +# Without high_pass_filter the threshold includes gravity, so it should exceed 1g. +INTERRUPT_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PIN, default="INT1"): cv.enum( + INTERRUPT_PIN_OPTIONS, upper=True + ), + cv.Optional(CONF_THRESHOLD, default="1.1g"): cv.All( + cv.float_with_unit("acceleration", "(g|G)?"), cv.positive_float + ), + cv.Optional(CONF_DURATION, default="0ms"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AXES, default=AXES): _axes, + cv.Optional(CONF_LATCHED, default=True): cv.boolean, + cv.Optional(CONF_ACTIVE_HIGH, default=True): cv.boolean, + cv.Optional(CONF_HIGH_PASS_FILTER, default=False): cv.boolean, + } +) + + +def _validate_odr_mode(config: ConfigType) -> ConfigType: + # 1620Hz exists only in low power mode; the 1344Hz code runs at 5376Hz there. + odr = config[CONF_ACCELEROMETER_ODR] + low_power = config[CONF_OPERATING_MODE] == "LOW_POWER" + if odr == "1620HZ" and not low_power: + raise cv.Invalid( + "'1620HZ' is only available with 'operating_mode: LOW_POWER'", + path=[CONF_ACCELEROMETER_ODR], + ) + if odr == "1344HZ" and low_power: + raise cv.Invalid( + "'1344HZ' is not available with 'operating_mode: LOW_POWER' " + "(that combination runs at 5376Hz)", + path=[CONF_ACCELEROMETER_ODR], + ) + return config + + +def _validate_interrupt(config: ConfigType) -> ConfigType: + if (interrupt := config.get(CONF_INTERRUPT)) is None: + return config + range_key = config[CONF_ACCELEROMETER_RANGE] + threshold = interrupt[CONF_THRESHOLD] + max_g = THRESHOLD_MAX_REG * THRESHOLD_LSB_MG[range_key] / 1000 + if threshold > max_g: + raise cv.Invalid( + f"'threshold' can be at most {max_g:g}g with 'accelerometer_range: {range_key}'", + path=[CONF_INTERRUPT, CONF_THRESHOLD], + ) + if not interrupt[CONF_HIGH_PASS_FILTER] and threshold <= 1.0: + _LOGGER.warning( + "lis3dh: a threshold of %sg or less includes gravity and may keep the interrupt " + "asserted at rest; set 'high_pass_filter: true' to compare against motion only", + threshold, + ) + odr_key = config[CONF_ACCELEROMETER_ODR] + duration_ms = interrupt[CONF_DURATION].total_milliseconds + if _duration_register(duration_ms, odr_key) > DURATION_MAX_REG: + max_ms = math.floor(DURATION_MAX_REG * 1000 / ODR_HZ[odr_key]) + raise cv.Invalid( + f"'duration' can be at most {max_ms}ms with 'accelerometer_odr: {odr_key}'", + path=[CONF_INTERRUPT, CONF_DURATION], + ) + return config + + +CONFIG_SCHEMA = cv.All( + motion_schema(LIS3DHComponent, has_accel=True, has_gyro=False) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum( + RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="100HZ"): cv.enum( + DATA_RATE_OPTIONS, upper=True + ), + cv.Optional(CONF_OPERATING_MODE, default="HIGH_RESOLUTION"): cv.enum( + OPERATING_MODE_OPTIONS, upper=True + ), + cv.Optional(CONF_INTERRUPT): INTERRUPT_SCHEMA, + } + ) + .extend(i2c.i2c_device_schema(0x18)), + _validate_odr_mode, + _validate_interrupt, +) + + +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_data_rate(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_operating_mode(config[CONF_OPERATING_MODE])) + + if (interrupt := config.get(CONF_INTERRUPT)) is not None: + threshold = _threshold_register( + interrupt[CONF_THRESHOLD], config[CONF_ACCELEROMETER_RANGE] + ) + axes_cfg = 0 + for axis in interrupt[CONF_AXES]: + axes_cfg |= INT_CFG_HIGH_EVENT[axis] + cg.add( + var.set_interrupt( + interrupt[CONF_PIN], + threshold, + _duration_register( + interrupt[CONF_DURATION].total_milliseconds, + config[CONF_ACCELEROMETER_ODR], + ), + axes_cfg, + interrupt[CONF_LATCHED], + interrupt[CONF_ACTIVE_HIGH], + interrupt[CONF_HIGH_PASS_FILTER], + ) + ) diff --git a/esphome/components/lis3dh/sensor.py b/esphome/components/lis3dh/sensor.py new file mode 100644 index 0000000000..15d19bcc0c --- /dev/null +++ b/esphome/components/lis3dh/sensor.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +from . import CONF_LIS3DH_ID, LIS3DHComponent + +# The LIS3DH temperature sensor is uncalibrated and only reports relative +# changes (roughly 1 °C per count), so a single decimal of accuracy is plenty. +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_LIS3DH_ID): cv.use_id(LIS3DHComponent), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_LIS3DH_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/component_tests/lis3dh/__init__.py b/tests/component_tests/lis3dh/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/lis3dh/test_motion.py b/tests/component_tests/lis3dh/test_motion.py new file mode 100644 index 0000000000..1d86761d4c --- /dev/null +++ b/tests/component_tests/lis3dh/test_motion.py @@ -0,0 +1,147 @@ +"""Tests for the lis3dh config cross-checks and interrupt threshold maths.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.lis3dh.motion import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_DURATION, + CONF_HIGH_PASS_FILTER, + CONF_INTERRUPT, + CONF_OPERATING_MODE, + CONF_THRESHOLD, + _duration_register, + _threshold_register, + _validate_interrupt, + _validate_odr_mode, +) +import esphome.config_validation as cv +from esphome.core import TimePeriod + + +def _interrupt_config( + range_key: str, + odr_key: str, + threshold_g: float, + duration_ms: int, + high_pass: bool = True, +) -> dict: + return { + CONF_ACCELEROMETER_RANGE: range_key, + CONF_ACCELEROMETER_ODR: odr_key, + CONF_INTERRUPT: { + CONF_THRESHOLD: threshold_g, + CONF_DURATION: TimePeriod(milliseconds=duration_ms), + CONF_HIGH_PASS_FILTER: high_pass, + }, + } + + +@pytest.mark.parametrize("mode", ["NORMAL", "HIGH_RESOLUTION"]) +def test_1620hz_needs_low_power(mode: str) -> None: + """1620Hz exists only in low power mode.""" + config = {CONF_ACCELEROMETER_ODR: "1620HZ", CONF_OPERATING_MODE: mode} + with pytest.raises(cv.Invalid, match="only available with"): + _validate_odr_mode(config) + + +def test_1344hz_is_rejected_in_low_power() -> None: + """The 1344Hz code runs at 5376Hz in low power mode.""" + config = {CONF_ACCELEROMETER_ODR: "1344HZ", CONF_OPERATING_MODE: "LOW_POWER"} + with pytest.raises(cv.Invalid, match="not available with"): + _validate_odr_mode(config) + + +def test_1620hz_low_power_is_accepted() -> None: + """The one valid 1620Hz combination passes through unchanged.""" + config = {CONF_ACCELEROMETER_ODR: "1620HZ", CONF_OPERATING_MODE: "LOW_POWER"} + assert _validate_odr_mode(config) is config + + +@pytest.mark.parametrize( + ("threshold_g", "range_key", "expected"), + [ + (0.0, "2G", 1), + (0.05, "16G", 1), + (1.1, "2G", 69), + (0.2, "2G", 13), + (2.032, "2G", 127), + (23.622, "16G", 127), + ], +) +def test_threshold_register(threshold_g: float, range_key: str, expected: int) -> None: + """INT1_THS is the nearest step and never zero.""" + assert _threshold_register(threshold_g, range_key) == expected + + +@pytest.mark.parametrize( + ("threshold_g", "range_key"), + [(2.04, "2G"), (3.0, "2G"), (4.07, "4G"), (23.7, "16G")], +) +def test_threshold_above_range_is_rejected(threshold_g: float, range_key: str) -> None: + """A threshold the range cannot represent would never fire, so it fails validation.""" + config = _interrupt_config(range_key, "100HZ", threshold_g, 0) + with pytest.raises(cv.Invalid, match="'threshold' can be at most"): + _validate_interrupt(config) + + +def test_threshold_within_range_is_accepted() -> None: + """The largest representable threshold is still valid.""" + config = _interrupt_config("2G", "100HZ", 2.032, 0) + assert _validate_interrupt(config) is config + + +@pytest.mark.parametrize( + ("duration_ms", "odr_key", "expected"), + [ + (0, "100HZ", 0), + (20, "100HZ", 2), + (1, "1620HZ", 2), + (10000, "1HZ", 10), + (4, "100HZ", 0), + ], +) +def test_duration_register(duration_ms: int, odr_key: str, expected: int) -> None: + """INT1_DURATION is the nearest number of samples at the data rate.""" + assert _duration_register(duration_ms, odr_key) == expected + + +@pytest.mark.parametrize( + ("duration_ms", "odr_key"), [(1280, "100HZ"), (128000, "1HZ"), (79, "1620HZ")] +) +def test_duration_above_register_is_rejected(duration_ms: int, odr_key: str) -> None: + """A duration that needs more than 127 samples fails validation.""" + config = _interrupt_config("2G", odr_key, 1.1, duration_ms) + with pytest.raises(cv.Invalid, match="'duration' can be at most"): + _validate_interrupt(config) + + +def test_duration_at_register_limit_is_accepted() -> None: + """127 samples is the largest INT1_DURATION value.""" + config = _interrupt_config("2G", "100HZ", 1.1, 1270) + assert _validate_interrupt(config) is config + + +def test_sub_gravity_threshold_without_filter_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Gravity alone can hold the pad asserted below 1g unless the high-pass filter is on.""" + config = _interrupt_config("2G", "100HZ", 0.5, 0, high_pass=False) + with caplog.at_level(logging.WARNING): + assert _validate_interrupt(config) is config + assert "high_pass_filter: true" in caplog.text + + +@pytest.mark.parametrize(("threshold_g", "high_pass"), [(1.1, False), (0.5, True)]) +def test_threshold_without_gravity_problem_is_silent( + caplog: pytest.LogCaptureFixture, threshold_g: float, high_pass: bool +) -> None: + """Above 1g, or with the filter on, no warning is logged.""" + config = _interrupt_config("2G", "100HZ", threshold_g, 0, high_pass=high_pass) + with caplog.at_level(logging.WARNING): + _validate_interrupt(config) + assert caplog.text == "" diff --git a/tests/components/lis3dh/common.yaml b/tests/components/lis3dh/common.yaml new file mode 100644 index 0000000000..54f492ecfa --- /dev/null +++ b/tests/components/lis3dh/common.yaml @@ -0,0 +1,67 @@ +motion: + - platform: lis3dh + id: lis3dh_main + i2c_id: i2c_bus + accelerometer_range: 2G + accelerometer_odr: 100HZ + operating_mode: HIGH_RESOLUTION + axis_map: + x: y + y: x + z: -z + # Motion (activity) interrupt - keeps the INT pad asserted while moving so it + # can be used as the deep_sleep wakeup_pin. + interrupt: + pin: INT1 + threshold: 1.1g + duration: 0ms + axes: [x, y, z] + latched: true + active_high: true + high_pass_filter: false + + # A second device covering the other side of each option: low power mode with + # the rate that only exists there, routed to INT2, active low, unlatched, and + # with the high-pass filter on. + - platform: lis3dh + id: lis3dh_low_power + i2c_id: i2c_bus + address: 0x19 + accelerometer_range: 16G + accelerometer_odr: 1620HZ + operating_mode: LOW_POWER + interrupt: + pin: INT2 + threshold: 0.05g + duration: 1ms + axes: [z] + latched: false + active_high: false + high_pass_filter: true + +sensor: + - platform: lis3dh + lis3dh_id: lis3dh_main + type: temperature + name: "LIS3DH Temperature" + + - platform: motion + motion_id: lis3dh_main + type: acceleration_x + name: "Accel X" + - platform: motion + motion_id: lis3dh_main + type: acceleration_y + name: "Accel Y" + - platform: motion + motion_id: lis3dh_main + type: acceleration_z + name: "Accel Z" + - platform: motion + motion_id: lis3dh_main + type: pitch + name: "Pitch" + - platform: motion + motion_id: lis3dh_main + type: roll + name: "Roll" diff --git a/tests/components/lis3dh/test.esp32-idf.yaml b/tests/components/lis3dh/test.esp32-idf.yaml new file mode 100644 index 0000000000..41a8a05165 --- /dev/null +++ b/tests/components/lis3dh/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lis3dh: !include common.yaml diff --git a/tests/components/lis3dh/test.esp8266-ard.yaml b/tests/components/lis3dh/test.esp8266-ard.yaml new file mode 100644 index 0000000000..63c7f2a94a --- /dev/null +++ b/tests/components/lis3dh/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + lis3dh: !include common.yaml From 83e5e32e2bcdbb6b15ad0761952999e8b77aa845 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Mon, 12 Oct 2026 14:52:05 -0700 Subject: [PATCH 214/268] [i2s_clockless_led_strip] Drives LED strips using I2S and DMA (#20255) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../i2s_clockless_led_strip/__init__.py | 1 + .../i2s_clockless_led_strip.cpp | 254 ++++++++++++++++++ .../i2s_clockless_led_strip.h | 56 ++++ .../i2s_clockless_led_strip/light.py | 86 ++++++ .../test.esp32-s3-idf.yaml | 11 + 6 files changed, 409 insertions(+) create mode 100644 esphome/components/i2s_clockless_led_strip/__init__.py create mode 100644 esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp create mode 100644 esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h create mode 100644 esphome/components/i2s_clockless_led_strip/light.py create mode 100644 tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8f1271b76a..ec2bddd548 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -276,6 +276,7 @@ esphome/components/i2c_device/* @gabest11 esphome/components/i2s_audio/* @jesserockz esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt +esphome/components/i2s_clockless_led_strip/* @j9brown esphome/components/iaqcore/* @yozik04 esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 diff --git a/esphome/components/i2s_clockless_led_strip/__init__.py b/esphome/components/i2s_clockless_led_strip/__init__.py new file mode 100644 index 0000000000..038e865f3a --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/__init__.py @@ -0,0 +1 @@ +DOMAIN = "i2s_clockless_led_strip" diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp new file mode 100644 index 0000000000..bf53d96a83 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp @@ -0,0 +1,254 @@ +#include "i2s_clockless_led_strip.h" + +#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM + +#include +#include + +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::i2s_clockless_led_strip { + +ESPHOME_LOG_TAG(TAG, "i2s_clockless_led_strip"); + +constexpr const char *const ERROR_ALLOCATION = "Allocation error"; +constexpr const char *const ERROR_I2S = "I2S error"; + +// These values are hardcoded for an LED strip at 800 kbps. +// +// Each bit of LED strip data gets expanded into 3 bits of I2S data, where a 0-bit expands into 100 +// (1/3 duty cycle) and a 1-bit expands into 110 (2/3 duty cycle. Each byte of LED strip data becomes +// one 24-bit I2S sample to be transmitted at a rate of 100000 samples per second in I2S TDM mode. +constexpr uint32_t I2S_SAMPLE_RATE_HZ = 100000; +constexpr size_t I2S_BYTES_PER_SAMPLE = 3; + +// Determine how many I2S samples are needed to feed the output for the specified duration and vice-versa. +constexpr size_t num_i2s_samples_from_duration_us(uint32_t duration_us) { + return (duration_us * I2S_SAMPLE_RATE_HZ + 999999) / 1000000; +} +constexpr uint32_t num_i2s_samples_to_duration_us(size_t samples) { return samples * 1000000ull / I2S_SAMPLE_RATE_HZ; } + +// Determine the number of I2S samples to pad with zeros after the LED data to encode the LED strip reset signal. +// Minimum 50 us for WS2811, 80 us for SK6812, 280 us for WS2812B-V5. +constexpr uint32_t I2S_RESET_DURATION_US = 300; +constexpr size_t I2S_RESET_SAMPLES = num_i2s_samples_from_duration_us(I2S_RESET_DURATION_US); +constexpr size_t I2S_RESET_BYTES = I2S_RESET_SAMPLES * I2S_BYTES_PER_SAMPLE; + +// Determine the number of I2S samples per DMA buffer to ensure that the interrupt-driven I2S on_sent callback +// doesn't run too frequently. For example, if the buffers were sized to hold just 1 RGBW pixel then they would be +// recycled approximately every 40 us (25000 Hz) which is way too fast. The buffer duration also imposes an upper +// bound on the maximum LED strip refresh rate. Round up to a multiple of 3 for `dma_frame_num` as required by +// the ESP-IDF programming guide when using 24-bit samples. +constexpr uint32_t I2S_BUFFER_DURATION_US_IDEAL = 2500; +constexpr size_t I2S_BUFFER_SAMPLES = (num_i2s_samples_from_duration_us(I2S_BUFFER_DURATION_US_IDEAL) + 2) / 3 * 3; +constexpr uint32_t I2S_BUFFER_DURATION_US_ACTUAL = num_i2s_samples_to_duration_us(I2S_BUFFER_SAMPLES); + +I2SClocklessLedStrip::I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors) + : color_data_bytes_(num_leds * channel_colors.bytes_per_led()), + channel_colors_(channel_colors), + num_leds_(num_leds), + pin_(pin) {} + +void I2SClocklessLedStrip::dump_config() { + ESP_LOGCONFIG(TAG, + "I2S Clockless LED Strip:\n" + " Pin: %u", + this->pin_); + char channel_colors[5]; + ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" + " Number of LEDs: %" PRIu16 "\n" + " DMA buffer: %" PRIuPTR " samples (%" PRIu32 " us)", + this->channel_colors_.to_string(channel_colors), this->num_leds_, I2S_BUFFER_SAMPLES, + I2S_BUFFER_DURATION_US_ACTUAL); +} + +float I2SClocklessLedStrip::get_setup_priority() const { return setup_priority::IO; } + +void I2SClocklessLedStrip::setup() { + if (!this->allocate_buffers_()) { + this->mark_failed(LOG_STR(ERROR_ALLOCATION)); + return; + } + + i2s_chan_config_t chan_config = { + .id = I2S_NUM_AUTO, + .role = I2S_ROLE_MASTER, + .dma_desc_num = 2, + .dma_frame_num = I2S_BUFFER_SAMPLES, + .auto_clear_after_cb = false, + .auto_clear_before_cb = false, + .allow_pd = false, + .intr_priority = 0, + }; + esp_err_t err = i2s_new_channel(&chan_config, &this->tx_handle_, NULL); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_new_channel: %s", esp_err_to_name(err)); + } else { + i2s_tdm_config_t tdm_config = { + .clk_cfg = + { + .sample_rate_hz = I2S_SAMPLE_RATE_HZ, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_384, + }, + .slot_cfg = + { + .data_bit_width = I2S_DATA_BIT_WIDTH_24BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_MONO, + .slot_mask = I2S_TDM_SLOT0, + .ws_width = 1, + .big_endian = true, + .total_slot = I2S_TDM_AUTO_SLOT_NUM, + }, + .gpio_cfg = + { + .mclk = I2S_GPIO_UNUSED, + .bclk = I2S_GPIO_UNUSED, + .ws = I2S_GPIO_UNUSED, + .dout = gpio_num_t(this->pin_), + .din = I2S_GPIO_UNUSED, + .invert_flags = + { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }, + }; + err = i2s_channel_init_tdm_mode(this->tx_handle_, &tdm_config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_channel_init_tdm_mode: %s", esp_err_to_name(err)); + } else { + i2s_event_callbacks_t event_callbacks = { + .on_sent = i2s_on_sent_callback, + }; + err = i2s_channel_register_event_callback(this->tx_handle_, &event_callbacks, this); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_channel_register_event_callback: %s", esp_err_to_name(err)); + } else { + err = i2s_channel_enable(this->tx_handle_); + if (err == ESP_OK) + return; // Success! + ESP_LOGE(TAG, "Error in i2s_channel_enable: %s", esp_err_to_name(err)); + } + } + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = {}; + } + this->mark_failed(LOG_STR(ERROR_I2S)); +} + +bool I2SClocklessLedStrip::allocate_buffers_() { + const size_t i2s_data_bytes = this->color_data_bytes_ * I2S_BYTES_PER_SAMPLE; + + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->i2s_data_ = allocator.allocate(i2s_data_bytes); + if (this->i2s_data_ != nullptr) { + this->color_data_ = allocator.allocate(this->color_data_bytes_); + if (this->color_data_ != nullptr) { + this->effect_data_ = allocator.allocate(this->num_leds_); + if (this->effect_data_ != nullptr) { + memset(this->i2s_data_, 0, i2s_data_bytes); + memset(this->color_data_, 0, this->color_data_bytes_); + memset(this->effect_data_, 0, this->num_leds_); + return true; + } + allocator.deallocate(this->color_data_, this->color_data_bytes_); + this->color_data_ = nullptr; + } + allocator.deallocate(this->i2s_data_, i2s_data_bytes); + this->i2s_data_ = nullptr; + } + return false; +} + +light::LightTraits I2SClocklessLedStrip::get_traits() { + auto traits = light::LightTraits(); + if (this->channel_colors_.has_white()) { + traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); + } else { + traits.set_supported_color_modes({light::ColorMode::RGB}); + } + return traits; +} + +void I2SClocklessLedStrip::write_state(light::LightState *state) { + if (this->is_failed()) + return; + + if (this->i2s_data_ready_.load(std::memory_order_acquire)) { + // Busy sending the last frame, try again later. + this->schedule_show(); + return; + } + + const size_t color_data_bytes = this->color_data_bytes_; + uint8_t *color_data = this->color_data_; + uint8_t *i2s_data = this->i2s_data_; + for (size_t i = 0; i < color_data_bytes; i++) { + const uint8_t color_byte = *(color_data++); + *(i2s_data++) = 0b10010010 | ((color_byte & 0x80) >> 1) | ((color_byte & 0x40) >> 3) | ((color_byte & 0x20) >> 5); + *(i2s_data++) = 0b01001001 | ((color_byte & 0x10) << 1) | ((color_byte & 0x08) >> 1); + *(i2s_data++) = 0b00100100 | ((color_byte & 0x04) << 5) | ((color_byte & 0x02) << 3) | ((color_byte & 0x01) << 1); + } + + this->i2s_data_sent_ = 0; + this->i2s_data_ready_.store(true, std::memory_order_release); + + this->mark_shown_(); +} + +bool IRAM_ATTR HOT I2SClocklessLedStrip::i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, + void *user_ctx) { + auto *const self = static_cast(user_ctx); + auto *const dma_buf = static_cast(event->dma_buf); + const size_t dma_buf_size = event->size; + + bool dma_buf_filled = false; + if (self->i2s_data_ready_.load(std::memory_order_acquire)) { + const size_t i2s_data_bytes = self->color_data_bytes_ * I2S_BYTES_PER_SAMPLE; + if (self->i2s_data_sent_ < i2s_data_bytes) { + const size_t i2s_data_remaining = i2s_data_bytes - self->i2s_data_sent_; + if (i2s_data_remaining >= dma_buf_size) { + memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, dma_buf_size); + } else { + memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, i2s_data_remaining); + memset(dma_buf + i2s_data_remaining, 0, dma_buf_size - i2s_data_remaining); + } + dma_buf_filled = true; + } + self->i2s_data_sent_ += dma_buf_size; + if (self->i2s_data_sent_ >= i2s_data_bytes + I2S_RESET_BYTES) { + self->i2s_data_ready_.store(false, std::memory_order_release); + } + } + // We only need to clear the buffer when the first byte is non-zero. Zero is not a value + // that can appear within the I2S data that gets copied into the front of the buffer so + // if the first byte is zero then the buffer must have already been cleared. + if (!dma_buf_filled && dma_buf[0] != 0) { + memset(dma_buf, 0, dma_buf_size); + } + return false; +} + +void I2SClocklessLedStrip::clear_effect_data() { memset(this->effect_data_, 0, this->num_leds_); } + +light::ESPColorView I2SClocklessLedStrip::get_view_internal(int32_t index) const { + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->color_data_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, + &this->effect_data_[index], + &this->correction_}; +} + +} // namespace esphome::i2s_clockless_led_strip + +#endif diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h new file mode 100644 index 0000000000..0c516a6bc8 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h @@ -0,0 +1,56 @@ +#pragma once + +#ifdef USE_ESP32 +#include +#endif + +#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM + +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" + +#include + +namespace esphome::i2s_clockless_led_strip { + +class I2SClocklessLedStrip final : public light::AddressableLight { + public: + I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors); + + void dump_config() override; + + float get_setup_priority() const override; + void setup() override; + + light::LightTraits get_traits() override; + + int32_t size() const override { return this->num_leds_; } + void clear_effect_data() override; + void write_state(light::LightState *state) override; + + protected: + static bool i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); + + bool allocate_buffers_(); + light::ESPColorView get_view_internal(int32_t index) const override; + + const size_t color_data_bytes_; + uint8_t *effect_data_{nullptr}; + uint8_t *color_data_{nullptr}; + uint8_t *i2s_data_{nullptr}; + size_t i2s_data_sent_{0}; + i2s_chan_handle_t tx_handle_{}; + const light::ChannelColors channel_colors_; + const uint16_t num_leds_; + const uint8_t pin_; + std::atomic i2s_data_ready_{false}; +}; + +} // namespace esphome::i2s_clockless_led_strip + +#endif diff --git a/esphome/components/i2s_clockless_led_strip/light.py b/esphome/components/i2s_clockless_led_strip/light.py new file mode 100644 index 0000000000..90a4f1c354 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/light.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass + +from esphome import pins +import esphome.codegen as cg +from esphome.components import esp32, light +from esphome.components.const import CONF_CHANNEL_COLORS +import esphome.config_validation as cv +from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID, CONF_PIN +from esphome.core import CORE +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@j9brown"] +DEPENDENCIES = ["esp32"] + +i2s_clockless_led_strip_ns = cg.esphome_ns.namespace("i2s_clockless_led_strip") +I2SClocklessLedStrip = i2s_clockless_led_strip_ns.class_( + "I2SClocklessLedStrip", light.AddressableLight +) + +# These variants define SOC_I2S_SUPPORTS_TDM +I2S_TDM_PORTS = { + esp32.VARIANT_ESP32C3: 1, + esp32.VARIANT_ESP32C5: 1, + esp32.VARIANT_ESP32C6: 1, + esp32.VARIANT_ESP32C61: 1, + esp32.VARIANT_ESP32H2: 1, + esp32.VARIANT_ESP32H4: 1, + esp32.VARIANT_ESP32P4: 3, + esp32.VARIANT_ESP32S3: 2, +} + +CONFIG_SCHEMA = cv.All( + esp32.only_on_variant( + supported=list(I2S_TDM_PORTS.keys()), + msg_prefix="I2S Clockless LED Strip", + ), + light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(I2SClocklessLedStrip), + cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, + cv.Required(CONF_CHANNEL_COLORS): light.validate_channel_colors, + } + ).extend(cv.COMPONENT_SCHEMA), +) + + +@dataclass +class I2SClocklessLedStripData: + num_ports: int = 0 + + +def _get_data() -> I2SClocklessLedStripData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = I2SClocklessLedStripData() + return CORE.data[DOMAIN] + + +def _final_validate(_: ConfigType) -> None: + _get_data().num_ports += 1 + variant = esp32.get_esp32_variant() + num_ports_available = I2S_TDM_PORTS.get(variant, 0) + if _get_data().num_ports > num_ports_available: + raise cv.Invalid( + f"Only {num_ports_available} I2S TDM ports are supported on {variant}" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + esp32.include_builtin_idf_component("esp_driver_i2s") + esp32.add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) + + var = cg.new_Pvariable( + config[CONF_OUTPUT_ID], + config[CONF_PIN], + config[CONF_NUM_LEDS], + light.channel_colors_struct(config[CONF_CHANNEL_COLORS]), + ) + + await light.register_light(var, config) + await cg.register_component(var, config) diff --git a/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml b/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..7331691f4d --- /dev/null +++ b/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +light: + - platform: i2s_clockless_led_strip + id: i2s_clockless_led_strip1 + pin: GPIO1 + num_leds: 100 + channel_colors: GRB + - platform: i2s_clockless_led_strip + id: i2s_clockless_led_strip2 + pin: GPIO2 + num_leds: 5 + channel_colors: RWGB From d7dd25a1bc1700b5d5c8c875be00f3c8896b3183 Mon Sep 17 00:00:00 2001 From: Ivan Novak <37712440+NovakIrs@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:57:16 +0200 Subject: [PATCH 215/268] [ld2410s] Add HLK-LD2410S presence radar component (#10452) Co-authored-by: NovakI Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ld2410s/__init__.py | 40 +++ esphome/components/ld2410s/binary_sensor.py | 23 ++ esphome/components/ld2410s/ld2410s.cpp | 314 +++++++++++++++++ esphome/components/ld2410s/ld2410s.h | 62 ++++ tests/components/ld2410s/common.yaml | 7 + tests/components/ld2410s/test.esp32-idf.yaml | 3 + .../components/ld2410s/test.esp8266-ard.yaml | 3 + tests/components/ld2410s/test.rp2040-ard.yaml | 3 + .../fixtures/uart_mock_ld2410s.yaml | 84 +++++ .../fixtures/uart_mock_ld2410s_link_lost.yaml | 75 ++++ .../fixtures/uart_mock_ld2410s_recovery.yaml | 100 ++++++ tests/integration/test_uart_mock_ld2410s.py | 329 ++++++++++++++++++ 13 files changed, 1044 insertions(+) create mode 100644 esphome/components/ld2410s/__init__.py create mode 100644 esphome/components/ld2410s/binary_sensor.py create mode 100644 esphome/components/ld2410s/ld2410s.cpp create mode 100644 esphome/components/ld2410s/ld2410s.h create mode 100644 tests/components/ld2410s/common.yaml create mode 100644 tests/components/ld2410s/test.esp32-idf.yaml create mode 100644 tests/components/ld2410s/test.esp8266-ard.yaml create mode 100644 tests/components/ld2410s/test.rp2040-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2410s.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml create mode 100644 tests/integration/test_uart_mock_ld2410s.py diff --git a/CODEOWNERS b/CODEOWNERS index ec2bddd548..2b77a16db8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -306,6 +306,7 @@ esphome/components/kuntze/* @ssieb esphome/components/lc709203f/* @ilikecake esphome/components/lcd_menu/* @numo68 esphome/components/ld2410/* @regevbr @sebcaps +esphome/components/ld2410s/* @NovakIrs esphome/components/ld2412/* @Rihan9 esphome/components/ld2420/* @descipher esphome/components/ld2450/* @hareeshmu diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py new file mode 100644 index 0000000000..1c39c7eab3 --- /dev/null +++ b/esphome/components/ld2410s/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@NovakIrs"] +DEPENDENCIES = ["uart"] +DOMAIN = "ld2410s" +MULTI_CONF = True + +CONF_LD2410S_ID = "ld2410s_id" + +ld2410s_ns = cg.esphome_ns.namespace(DOMAIN) +LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD2410SComponent), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + require_tx=True, + require_rx=True, + baud_rate=115200, + parity="NONE", + stop_bits=1, +) + + +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) diff --git a/esphome/components/ld2410s/binary_sensor.py b/esphome/components/ld2410s/binary_sensor.py new file mode 100644 index 0000000000..83e0a36cba --- /dev/null +++ b/esphome/components/ld2410s/binary_sensor.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY, ICON_ACCOUNT +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + icon=ICON_ACCOUNT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) diff --git a/esphome/components/ld2410s/ld2410s.cpp b/esphome/components/ld2410s/ld2410s.cpp new file mode 100644 index 0000000000..6462236ac9 --- /dev/null +++ b/esphome/components/ld2410s/ld2410s.cpp @@ -0,0 +1,314 @@ +#include "ld2410s.h" +#include +#include +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ld2410s { + +ESPHOME_LOG_TAG(TAG, "ld2410s"); + +static constexpr uint8_t SHORT_DATA_FRAME_HEADER = 0x6E; +static constexpr uint8_t SHORT_DATA_FRAME_FOOTER[] PROGMEM = {0x62}; +static constexpr uint8_t SHORT_DATA_FRAME_SIZE = 5; // header, state, distance low, distance high, footer +static constexpr uint8_t STD_DATA_FRAME_HEADER[] PROGMEM = {0xF4, 0xF3, 0xF2, 0xF1}; +static constexpr uint8_t STD_DATA_FRAME_FOOTER[] PROGMEM = {0xF8, 0xF7, 0xF6, 0xF5}; +static constexpr uint8_t CMD_FRAME_HEADER[] PROGMEM = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[] PROGMEM = {0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t LONG_HEADER_SIZE = 4; +static constexpr uint8_t LENGTH_FIELD_SIZE = 2; +static constexpr uint8_t LONG_PAYLOAD_POS = LONG_HEADER_SIZE + LENGTH_FIELD_SIZE; + +static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word of an acknowledgement +static constexpr uint16_t CONFIG_MODE_START_CMD = 0x00FF; +static constexpr uint16_t CONFIG_MODE_START_VALUE = 0x0001; +static constexpr uint16_t CONFIG_MODE_END_CMD = 0x00FE; +static constexpr uint16_t OUTPUT_MODE_SWITCH_CMD = 0x007A; +// Minimal output mode (short frames carrying presence and distance only) is selected by six zero bytes +static constexpr uint8_t OUTPUT_MODE_MINIMAL_SIZE = 6; +static constexpr uint8_t CMD_FRAME_MAX_SIZE = + LONG_PAYLOAD_POS + 2 + OUTPUT_MODE_MINIMAL_SIZE + sizeof(CMD_FRAME_FOOTER); + +// Everything the module needs at start: enter configuration mode, pick the output mode, leave it +static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, OUTPUT_MODE_SWITCH_CMD, + CONFIG_MODE_END_CMD}; +static constexpr uint8_t INIT_SEQUENCE_LENGTH = sizeof(INIT_SEQUENCE) / sizeof(INIT_SEQUENCE[0]); +static uint16_t init_command(uint8_t step) { return progmem_read_uint16(&INIT_SEQUENCE[step]); } +static constexpr uint8_t INIT_MAX_TIMEOUTS = 2; +static constexpr uint32_t ACK_TIMEOUT_MS = 300; +static constexpr uint32_t COMMAND_GAP_MS = 300; // pause after an acknowledgement before the next command +static constexpr uint32_t REINIT_PAUSE_MS = 15000; // pause before starting over after the module stopped answering +static constexpr uint32_t LINK_TIMEOUT_MS = 5000; // the module streams frames continuously; silence means it is gone + +static constexpr uint8_t DATA_TYPE_STANDARD = 0x01; + +void LD2410SComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LD2410S:"); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_); +#endif +} + +void LD2410SComponent::loop() { + uint8_t chunk[16]; + // Take the count once so one pass handles a bounded number of bytes + size_t avail = this->available(); + while (avail > 0) { + const size_t len = std::min(avail, sizeof(chunk)); + if (!this->read_array(chunk, len)) { + ESP_LOGV(TAG, "UART read of %u bytes failed", static_cast(len)); + break; + } + for (size_t i = 0; i < len; i++) { + this->receive_byte_(chunk[i]); + } + avail -= len; + } + const uint32_t now = App.get_loop_component_start_time(); + this->run_init_sequence_(now); + if (this->init_step_ >= INIT_SEQUENCE_LENGTH && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { + // The count tells real silence apart from frames that never parse (wrong baud rate or firmware) + ESP_LOGW(TAG, "No data for %" PRIu32 " s (%u frames dropped), starting over", LINK_TIMEOUT_MS / 1000, + this->dropped_frames_); + this->dropped_frames_ = 0; + this->status_set_warning(); + this->init_step_ = 0; + this->init_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now; + } +} + +void LD2410SComponent::run_init_sequence_(uint32_t now) { + if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast(now - this->next_send_at_) < 0) { + return; + } + const uint16_t command = init_command(this->init_step_); + if (this->awaiting_ack_) { + if (++this->init_timeouts_ > INIT_MAX_TIMEOUTS) { + ESP_LOGW(TAG, "No acknowledgement for command %04X, starting over in %" PRIu32 " s", command, + REINIT_PAUSE_MS / 1000); + // Leave configuration mode whatever state the module is in, then begin again + this->send_command_(CONFIG_MODE_END_CMD); + this->init_step_ = 0; + this->init_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now + REINIT_PAUSE_MS; + return; + } + ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command); + } + this->send_command_(command); + this->awaiting_ack_ = true; + this->next_send_at_ = now + ACK_TIMEOUT_MS; +} + +void LD2410SComponent::send_command_(uint16_t command) { + uint8_t frame[CMD_FRAME_MAX_SIZE]; + progmem_memcpy(frame, CMD_FRAME_HEADER, LONG_HEADER_SIZE); + uint8_t pos = LONG_PAYLOAD_POS; + frame[pos++] = command & 0xFF; + frame[pos++] = command >> 8; + switch (command) { + case OUTPUT_MODE_SWITCH_CMD: + memset(&frame[pos], 0, OUTPUT_MODE_MINIMAL_SIZE); + pos += OUTPUT_MODE_MINIMAL_SIZE; + break; + case CONFIG_MODE_START_CMD: + frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF; + frame[pos++] = CONFIG_MODE_START_VALUE >> 8; + break; + default: + break; + } + frame[LONG_HEADER_SIZE] = pos - LONG_PAYLOAD_POS; // payload length, little-endian + frame[LONG_HEADER_SIZE + 1] = 0; + progmem_memcpy(&frame[pos], CMD_FRAME_FOOTER, sizeof(CMD_FRAME_FOOTER)); + pos += sizeof(CMD_FRAME_FOOTER); + this->write_array(frame, pos); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char hex_buf[format_hex_pretty_size(CMD_FRAME_MAX_SIZE)]; + ESP_LOGVV(TAG, "> %s", format_hex_pretty_to(hex_buf, frame, pos, ' ')); +#endif +} + +void LD2410SComponent::receive_byte_(uint8_t byte) { + if (this->rx_len_ >= RX_BUFFER_SIZE) { + this->reset_frame_(); + } + this->rx_buffer_[this->rx_len_++] = byte; + + if (this->frame_type_ == FrameType::NONE) { + if (this->rx_len_ == 1 && byte == SHORT_DATA_FRAME_HEADER) { + this->frame_type_ = FrameType::SHORT_DATA; + this->expected_len_ = SHORT_DATA_FRAME_SIZE; + return; + } + // The two long headers are told apart once all four bytes are in + uint8_t std_header[LONG_HEADER_SIZE]; + uint8_t cmd_header[LONG_HEADER_SIZE]; + progmem_memcpy(std_header, STD_DATA_FRAME_HEADER, LONG_HEADER_SIZE); + progmem_memcpy(cmd_header, CMD_FRAME_HEADER, LONG_HEADER_SIZE); + const bool std_prefix = memcmp(this->rx_buffer_, std_header, this->rx_len_) == 0; + const bool cmd_prefix = memcmp(this->rx_buffer_, cmd_header, this->rx_len_) == 0; + if (std_prefix || cmd_prefix) { + if (this->rx_len_ == LONG_HEADER_SIZE) { + this->frame_type_ = std_prefix ? FrameType::STD_DATA : FrameType::COMMAND; + } + return; + } + // Not a header: drop what was collected, and try this byte as the start of the next frame + const bool retry = this->rx_len_ > 1; + this->reset_frame_(); + if (retry) { + this->receive_byte_(byte); + } + return; + } + + if (this->expected_len_ == 0) { + if (this->rx_len_ < LONG_PAYLOAD_POS) { + return; + } + // Standard data and command frames carry a little-endian payload length after the header + const uint16_t payload_len = + encode_uint16(this->rx_buffer_[LONG_HEADER_SIZE + 1], this->rx_buffer_[LONG_HEADER_SIZE]); + if (payload_len > RX_BUFFER_SIZE - LONG_PAYLOAD_POS - sizeof(CMD_FRAME_FOOTER)) { + ESP_LOGV(TAG, "Dropping frame with a %u byte payload, larger than the receive buffer", payload_len); + this->count_dropped_frame_(); + this->reset_frame_(); + return; + } + this->expected_len_ = LONG_PAYLOAD_POS + payload_len + sizeof(CMD_FRAME_FOOTER); + return; + } + + if (this->rx_len_ < this->expected_len_) { + return; + } + if (this->handle_frame_() || this->rescanning_) { + this->reset_frame_(); + return; + } + // A truncated long frame swallowed the bytes that followed it; try them again as new frames, once + uint8_t rest[RX_BUFFER_SIZE]; + const uint8_t rest_len = this->rx_len_ - 1; + memcpy(rest, &this->rx_buffer_[1], rest_len); + this->reset_frame_(); + this->rescanning_ = true; + for (uint8_t i = 0; i < rest_len; i++) { + this->receive_byte_(rest[i]); + } + this->rescanning_ = false; +} + +bool LD2410SComponent::handle_frame_() { + const uint8_t *footer_src; + uint8_t footer_size = sizeof(CMD_FRAME_FOOTER); + uint8_t payload_pos = LONG_PAYLOAD_POS; + switch (this->frame_type_) { + case FrameType::SHORT_DATA: + footer_src = SHORT_DATA_FRAME_FOOTER; + footer_size = sizeof(SHORT_DATA_FRAME_FOOTER); + payload_pos = 1; + break; + case FrameType::STD_DATA: + footer_src = STD_DATA_FRAME_FOOTER; + break; + default: + footer_src = CMD_FRAME_FOOTER; + break; + } + uint8_t footer[sizeof(CMD_FRAME_FOOTER)]; + progmem_memcpy(footer, footer_src, footer_size); + if (memcmp(&this->rx_buffer_[this->rx_len_ - footer_size], footer, footer_size) != 0) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(RX_BUFFER_SIZE)]; + ESP_LOGV(TAG, "Footer does not match header: %s", + format_hex_pretty_to(hex_buf, this->rx_buffer_, this->rx_len_, ' ')); +#endif + this->count_dropped_frame_(); + return false; + } + const uint8_t *payload = &this->rx_buffer_[payload_pos]; + const uint16_t payload_len = this->rx_len_ - payload_pos - footer_size; + switch (this->frame_type_) { + case FrameType::SHORT_DATA: + case FrameType::STD_DATA: + if (!this->handle_data_frame_(payload, payload_len)) { + this->count_dropped_frame_(); + break; + } + this->last_frame_at_ = App.get_loop_component_start_time(); + this->dropped_frames_ = 0; + break; + default: + this->handle_command_ack_(payload, payload_len); + break; + } + return true; +} + +bool LD2410SComponent::handle_data_frame_(const uint8_t *payload, uint16_t len) { + uint8_t state; + if (this->frame_type_ == FrameType::SHORT_DATA) { + // [state][distance low][distance high] + state = payload[0]; + } else if (len >= 4 && payload[0] == DATA_TYPE_STANDARD) { + // [type][state][distance low][distance high]...; the module only sends these before the init + // sequence switches it to minimal output + state = payload[1]; + } else { + if (len >= 1) { + ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + } + return true; + } + // States 0 and 1 mean no target, 2 and 3 a target; anything else is line noise that happened to + // carry the header and footer bytes + if (state > 3) { + ESP_LOGV(TAG, "Dropping frame with impossible state %u", state); + return false; + } + this->publish_target_(state > 1); + return true; +} + +void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len) { + if (len < 4) { + ESP_LOGV(TAG, "Acknowledgement too short (%u bytes)", len); + return; + } + const uint16_t command_word = encode_uint16(payload[1], payload[0]); + const uint16_t ack = encode_uint16(payload[3], payload[2]); + if (!this->awaiting_ack_ || command_word != (init_command(this->init_step_) | CMD_CONFIRMATION)) { + ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); + return; + } + if (ack != 0) { + // Leave the command pending so the timeout resends it + ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", init_command(this->init_step_), ack); + return; + } + this->awaiting_ack_ = false; + this->init_timeouts_ = 0; + this->init_step_++; + this->next_send_at_ = App.get_loop_component_start_time() + COMMAND_GAP_MS; + if (this->init_step_ >= INIT_SEQUENCE_LENGTH) { + ESP_LOGD(TAG, "Setup done"); + this->last_frame_at_ = App.get_loop_component_start_time(); + this->status_clear_warning(); + } +} + +void LD2410SComponent::publish_target_(bool target) { +#ifdef USE_BINARY_SENSOR + if (this->target_binary_sensor_ != nullptr) { + this->target_binary_sensor_->publish_state(target); + } +#endif +} + +} // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s.h b/esphome/components/ld2410s/ld2410s.h new file mode 100644 index 0000000000..4af809836c --- /dev/null +++ b/esphome/components/ld2410s/ld2410s.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::ld2410s { + +// Largest frame read here is the standard data frame: 4 header + 2 length + payload + 4 footer +static constexpr size_t RX_BUFFER_SIZE = 96; +static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte"); + +class LD2410SComponent final : public Component, public uart::UARTDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(target) +#endif + + public: + void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence + void loop() override; + void dump_config() override; + + protected: + enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND }; + + void receive_byte_(uint8_t byte); + /// Returns false when the footer does not match, so the bytes can be scanned again. + bool handle_frame_(); + /// Returns false for a data frame whose state byte is impossible, so it counts as dropped. + bool handle_data_frame_(const uint8_t *payload, uint16_t len); + void handle_command_ack_(const uint8_t *payload, uint16_t len); + void count_dropped_frame_() { + if (this->dropped_frames_ < UINT8_MAX) { + this->dropped_frames_++; + } + } + void reset_frame_() { + this->rx_len_ = 0; + this->expected_len_ = 0; + this->frame_type_ = FrameType::NONE; + } + void run_init_sequence_(uint32_t now); + void send_command_(uint16_t command); + void publish_target_(bool target); + + uint32_t next_send_at_{0}; + uint32_t last_frame_at_{0}; + uint8_t rx_buffer_[RX_BUFFER_SIZE]; + uint8_t rx_len_{0}; + uint8_t expected_len_{0}; + uint8_t init_step_{0}; + uint8_t init_timeouts_{0}; + uint8_t dropped_frames_{0}; // since the last accepted data frame, saturates at 255 + FrameType frame_type_{FrameType::NONE}; + bool awaiting_ack_{false}; + bool rescanning_{false}; +}; + +} // namespace esphome::ld2410s diff --git a/tests/components/ld2410s/common.yaml b/tests/components/ld2410s/common.yaml new file mode 100644 index 0000000000..33b213d363 --- /dev/null +++ b/tests/components/ld2410s/common.yaml @@ -0,0 +1,7 @@ +ld2410s: + uart_id: uart_bus + +binary_sensor: + - platform: ld2410s + has_target: + name: Presence diff --git a/tests/components/ld2410s/test.esp32-idf.yaml b/tests/components/ld2410s/test.esp32-idf.yaml new file mode 100644 index 0000000000..a82d5fd343 --- /dev/null +++ b/tests/components/ld2410s/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + ld2410s: !include common.yaml diff --git a/tests/components/ld2410s/test.esp8266-ard.yaml b/tests/components/ld2410s/test.esp8266-ard.yaml new file mode 100644 index 0000000000..7346cb4670 --- /dev/null +++ b/tests/components/ld2410s/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld2410s: !include common.yaml diff --git a/tests/components/ld2410s/test.rp2040-ard.yaml b/tests/components/ld2410s/test.rp2040-ard.yaml new file mode 100644 index 0000000000..c3969bdf0d --- /dev/null +++ b/tests/components/ld2410s/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + ld2410s: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_ld2410s.yaml b/tests/integration/fixtures/uart_mock_ld2410s.yaml new file mode 100644 index 0000000000..113e0881e6 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s.yaml @@ -0,0 +1,84 @@ +esphome: + name: uart-mock-ld2410s-test + +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 ld2410s'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: 115200 + auto_start: false + responses: + # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Output-mode switch to minimal, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + # Config-mode end, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + injections: + # Short frame, state 2 (target), distance 300 -> presence on + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0 (no target) -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard data frame, type 1, state 2, distance 300 -> presence on + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x02, 0x2C, 0x01, 0x00, 0xF8, 0xF7, 0xF6, 0xF5] + # Short frame, state 1 (no target) -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x01, 0x00, 0x00, 0x62] + # Noise byte directly followed by a short frame, state 2 -> presence on + - delay: 100ms + inject_rx: [0xAA, 0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Partial standard header byte directly followed by a short frame, state 3 -> presence on + - delay: 100ms + inject_rx: [0xF4, 0x6E, 0x03, 0x2C, 0x01, 0x62] + # Short frame with the impossible state 7, dropped, then a short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x07, 0x00, 0x00, 0x62, 0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard data frame, type 1, with the impossible state 7, dropped, then a short frame, state 2 + # -> presence on + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x07, 0x2C, 0x01, 0x00, 0xF8, 0xF7, 0xF6, 0xF5, 0x6E, + 0x02, 0x2C, 0x01, 0x62, + ] + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml new file mode 100644 index 0000000000..5f06985c7b --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml @@ -0,0 +1,75 @@ +esphome: + name: uart-mock-ld2410s-link-lost + +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 ld2410s'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: 115200 + auto_start: false + responses: + # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Output-mode switch to minimal, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + # Config-mode end, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + injections: + # Short frames every second for 7 s, longer than the 5 s link timeout, so they must keep the link alive + # t=1 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=2 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=3 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=4 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=5 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=6 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=7 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Then silence: the link watchdog must start the init sequence over + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml b/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml new file mode 100644 index 0000000000..3502ccb85c --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml @@ -0,0 +1,100 @@ +esphome: + name: uart-mock-ld2410s-recovery + +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 ld2410s's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# No responses: the init commands go unanswered, so the test also covers the resend and give-up path; +# this does not affect receiving data frames +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + injections: + # Short frame, state 2 -> presence on (baseline) + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Standard header with a 255 byte length field (over the 96 byte buffer), three payload bytes, + # then a short frame, state 0 -> frame dropped, presence off + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard frame declaring 5 payload bytes but carrying 3, then a short frame, state 2: the short + # frame is read as the rest of the declared length, so the frame stays one byte short + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x02, 0x2C, 0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0: its first byte completes the truncated frame (footer mismatch); the + # rescan of the swallowed bytes rescues the state 2 short frame -> presence on, and leaves this + # frame's header pending, so the rest of it completes -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Short frame, state 2 -> presence on + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Command frame with zeros where the footer belongs, then a short frame, state 0 -> presence off + - delay: 100ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6E, 0x00, + 0x00, 0x00, 0x62, + ] + # 100 bytes that never start a header, then a short frame, state 2 -> presence on + - delay: 100ms + inject_rx: + [ + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x6E, 0x02, 0x2C, 0x01, 0x62, + ] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard frame of the largest size that fits (86 byte payload: type 1, state 2, distance 300, + # 82 padding bytes) -> presence on + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x56, 0x00, 0x01, 0x02, 0x2C, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0xF7, 0xF6, 0xF5, + ] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/test_uart_mock_ld2410s.py b/tests/integration/test_uart_mock_ld2410s.py new file mode 100644 index 0000000000..8cd9b4b8dd --- /dev/null +++ b/tests/integration/test_uart_mock_ld2410s.py @@ -0,0 +1,329 @@ +"""Integration tests for the LD2410S component with mock UART. + +test_uart_mock_ld2410s: + 1. Init sequence - config-mode start, output-mode switch and config-mode end go + out in order, each after the previous one is acknowledged, then the + component logs "Setup done" and clears its warning + 2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard + data frame + 3. Resync - a noise byte or a partial header directly before a short frame + does not hide that frame + 4. A short frame and a standard frame whose state byte is above 3 are + dropped without publishing, and the short frame after each still publishes + +test_uart_mock_ld2410s_recovery (the mock never answers the init commands): + 1. Config-mode start is resent after the acknowledgement timeout + 2. Once the retries run out, config-mode end is sent and the component warns + that it is starting over + 3. A standard frame whose length field exceeds the receive buffer is dropped + 4. A truncated standard frame swallows the bytes after it up to its declared + length and fails the footer check; the swallowed bytes are rescanned, so + the short frames hidden in them still publish + 5. A command frame with a bad footer is dropped + 6. 100 bytes that never start a header are skipped + 7. A standard frame of the largest size that fits (86 byte payload) is accepted + +test_uart_mock_ld2410s_link_lost: + 1. Short frames arriving for longer than the 5 s link timeout keep the link up + 2. 5 s of silence logs "No data for 5 s (0 frames dropped), starting over", sets the warning flag + and sends a new config-mode start +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import APIClient, ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +CONFIG_START_TX = "FD:FC:FB:FA:04:00:FF:00:01:00:04:03:02:01" +OUTPUT_MODE_TX = "FD:FC:FB:FA:08:00:7A:00:00:00:00:00:00:00:04:03:02:01" +CONFIG_END_TX = "FD:FC:FB:FA:02:00:FE:00:04:03:02:01" + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + +# has_target after each injection: short 2, short 0, standard frame state 2, +# short 1, noise + short 2, short 0, partial header + short 3, +# short 7 (dropped) + short 0, standard frame state 7 (dropped) + short 2 +EXPECTED_PRESENCE = [True, False, True, False, True, False, True, False, True] +# The short frame and the standard frame with state 7 are logged as dropped +EXPECTED_DROPPED_STATES = ["7", "7"] + +# has_target after each recovery phase (see uart_mock_ld2410s_recovery.yaml). +# The truncated frame swallows the state 2 short frame after it plus the header +# byte of the following state 0 short frame; the footer mismatch rescans those +# bytes, which publishes the swallowed frame and resumes the next one. +EXPECTED_RECOVERY_PRESENCE = [ + True, # baseline short frame + False, # short frame after the dropped oversized length field + True, # rescued: short frame swallowed by the truncated standard frame + False, # short frame whose header byte completed the truncated frame + True, # short frame + False, # short frame after the command frame with a bad footer + True, # short frame after 100 junk bytes + False, # short frame + True, # maximum-size standard frame + False, # short frame +] +# The truncated frame takes the next 6 bytes to reach its declared length +TRUNCATED_FRAME_HEX = "F4 F3 F2 F1 05 00 01 02 2C 6E 02 2C 01 62 6E" +BAD_COMMAND_FRAME_HEX = "FD FC FB FA 04 00 FE 01 00 00 00 00 00 00" + + +def _tx_frame(line: str) -> str | None: + """Return the hex payload of a uart_mock TX log line, or None.""" + if "uart_mock" not in line or "TX " not in line or "bytes: " not in line: + return None + return ANSI_ESCAPE.sub("", line.split("bytes: ", 1)[1]).strip() + + +async def _start_scenario(client: APIClient, collector: SensorStateCollector) -> None: + """Subscribe the collector past the initial states, then start the mock scenario.""" + 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") + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + + +async def _wait_for_presence( + collector: SensorStateCollector, waiter: asyncio.Future[bool] +) -> None: + try: + await asyncio.wait_for(waiter, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for presence updates. Received: " + f"{collector.binary_states['has_target']}" + ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the LD2410S init sequence, presence parsing and receiver resync.""" + loop = asyncio.get_running_loop() + tx_frames: list[str] = [] + setup_done = loop.create_future() + warning_cleared = loop.create_future() + dropped_states: list[str] = [] + + def line_callback(line: str) -> None: + if (frame := _tx_frame(line)) is not None: + tx_frames.append(frame) + if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): + setup_done.set_result(True) + if "ld2410s cleared Warning flag" in line and not warning_cleared.done(): + warning_cleared.set_result(True) + if "Dropping frame with impossible state " in line: + dropped_states.append( + ANSI_ESCAPE.sub("", line.split("state ", 1)[1]).strip() + ) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + try: + await asyncio.wait_for(asyncio.gather(setup_done, warning_cleared), 5.0) + except TimeoutError: + pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") + + assert tx_frames[:3] == [CONFIG_START_TX, OUTPUT_MODE_TX, CONFIG_END_TX] + + await _start_scenario(client, collector) + await _wait_for_presence(collector, all_presence) + # Let any unexpected extra update arrive before comparing + await asyncio.sleep(0.3) + + assert collector.binary_states["has_target"] == EXPECTED_PRESENCE + assert dropped_states == EXPECTED_DROPPED_STATES + # Data frames must not restart the init sequence + assert len(tx_frames) == 3, tx_frames + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s_recovery( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the unanswered-command retry, and that bad frames do not stop later ones.""" + loop = asyncio.get_running_loop() + tx_frames: list[tuple[float, str]] = [] + resent = loop.create_future() + gave_up = loop.create_future() + oversized_dropped = loop.create_future() + footer_mismatches: list[str] = [] + + def line_callback(line: str) -> None: + if (frame := _tx_frame(line)) is not None: + tx_frames.append((loop.time(), frame)) + if "No acknowledgement for command 00FF, resending" in line and not ( + resent.done() + ): + resent.set_result(True) + if ( + "No acknowledgement for command 00FF, starting over in 15 s" in line + and not gave_up.done() + ): + gave_up.set_result(True) + if ( + "Dropping frame with a 255 byte payload, larger than the receive buffer" + in line + and not oversized_dropped.done() + ): + oversized_dropped.set_result(True) + if "Footer does not match header: " in line: + footer_mismatches.append( + ANSI_ESCAPE.sub("", line.split("header: ", 1)[1]).strip() + ) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: ( + len(collector.binary_states["has_target"]) + >= len(EXPECTED_RECOVERY_PRESENCE) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await _start_scenario(client, collector) + await _wait_for_presence(collector, all_presence) + try: + await asyncio.wait_for(asyncio.gather(resent, gave_up), 5.0) + except TimeoutError: + pytest.fail(f"No resend or give-up warning. TX frames: {tx_frames}") + # Let any unexpected extra update arrive before comparing + await asyncio.sleep(0.3) + + # Recovery shows in the published states alone; the footer log below is + # only a cross-check that needs the fixture's VERBOSE logger level + assert collector.binary_states["has_target"] == EXPECTED_RECOVERY_PRESENCE + assert oversized_dropped.done(), "Oversized length field was not dropped" + assert footer_mismatches == [TRUNCATED_FRAME_HEX, BAD_COMMAND_FRAME_HEX] + + frames = [frame for _, frame in tx_frames] + # First send plus INIT_MAX_TIMEOUTS (2) resends, then config-mode end; + # the restart after the 15 s pause is out of the test's time window + assert frames == [CONFIG_START_TX] * 3 + [CONFIG_END_TX] + # Resends follow the 300 ms acknowledgement timeout + gaps = [b[0] - a[0] for a, b in zip(tx_frames, tx_frames[1:], strict=False)] + assert all(0.2 <= gap <= 1.0 for gap in gaps), gaps + + +# has_target after each short frame in uart_mock_ld2410s_link_lost.yaml +EXPECTED_LINK_PRESENCE = [True, False, True, False, True, False, True] +LINK_TIMEOUT_S = 5.0 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s_link_lost( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that short frames keep the link alive and silence restarts the init.""" + loop = asyncio.get_running_loop() + tx_frames: list[str] = [] + rx_injected_at: list[float] = [] + setup_done = loop.create_future() + link_lost = loop.create_future() + warning_set_after_loss = loop.create_future() + restarted = loop.create_future() + config_start_before_loss = 0 + + def line_callback(line: str) -> None: + nonlocal config_start_before_loss + if (frame := _tx_frame(line)) is not None: + tx_frames.append(frame) + if frame == CONFIG_START_TX and link_lost.done() and not restarted.done(): + restarted.set_result(True) + if "uart_mock" in line and "Injecting " in line and "(injection " in line: + rx_injected_at.append(loop.time()) + if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): + setup_done.set_result(True) + if ( + "No data for 5 s (0 frames dropped), starting over" in line + and not link_lost.done() + ): + config_start_before_loss = tx_frames.count(CONFIG_START_TX) + link_lost.set_result(loop.time()) + if ( + link_lost.done() + and "ld2410s set Warning flag" in line + and not warning_set_after_loss.done() + ): + warning_set_after_loss.set_result(True) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: ( + len(collector.binary_states["has_target"]) >= len(EXPECTED_LINK_PRESENCE) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + try: + await asyncio.wait_for(setup_done, 5.0) + except TimeoutError: + pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") + + await _start_scenario(client, collector) + try: + await asyncio.wait_for(all_presence, timeout=10.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for presence updates. Received: " + f"{collector.binary_states['has_target']}" + ) + assert collector.binary_states["has_target"] == EXPECTED_LINK_PRESENCE + + try: + lost_at = await asyncio.wait_for(link_lost, timeout=7.0) + await asyncio.wait_for( + asyncio.gather(warning_set_after_loss, restarted), timeout=1.0 + ) + except TimeoutError: + pytest.fail( + f"No link-lost warning, warning flag or restart. TX frames: {tx_frames}" + ) + + # The stream ran past the link timeout, so only the silence after it counts + silence = lost_at - rx_injected_at[-1] + assert silence >= LINK_TIMEOUT_S - 0.5, ( + f"Link declared lost {silence:.2f} s after the last frame" + ) + + # The init sequence starts over with a new config-mode start + assert config_start_before_loss == 1, tx_frames + assert tx_frames.count(CONFIG_START_TX) == 2, tx_frames From b88eafcd00070de1c894b764430f21f3a860743a Mon Sep 17 00:00:00 2001 From: Ryan Gammon Date: Mon, 12 Oct 2026 15:05:34 -0700 Subject: [PATCH 216/268] [bq27220] Add BQ27220 battery fuel gauge component (#17702) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bq27220/__init__.py | 2 + esphome/components/bq27220/bq27220.cpp | 93 ++++++++++++++ esphome/components/bq27220/bq27220.h | 44 +++++++ esphome/components/bq27220/sensor.py | 123 +++++++++++++++++++ tests/components/bq27220/common.yaml | 19 +++ tests/components/bq27220/test.esp32-idf.yaml | 3 + 7 files changed, 285 insertions(+) create mode 100644 esphome/components/bq27220/__init__.py create mode 100644 esphome/components/bq27220/bq27220.cpp create mode 100644 esphome/components/bq27220/bq27220.h create mode 100644 esphome/components/bq27220/sensor.py create mode 100644 tests/components/bq27220/common.yaml create mode 100644 tests/components/bq27220/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2b77a16db8..87a89a14f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -101,6 +101,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bq27220/* @rggammon esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core diff --git a/esphome/components/bq27220/__init__.py b/esphome/components/bq27220/__init__.py new file mode 100644 index 0000000000..2a5d7bc1d7 --- /dev/null +++ b/esphome/components/bq27220/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@rggammon"] +DOMAIN = "bq27220" diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp new file mode 100644 index 0000000000..d2f7961e02 --- /dev/null +++ b/esphome/components/bq27220/bq27220.cpp @@ -0,0 +1,93 @@ +#include "bq27220.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::bq27220 { + +ESPHOME_LOG_TAG(TAG, "bq27220"); + +// Standard command registers (16-bit, little-endian), SLUUBD4A +static constexpr uint8_t REG_CONTROL = 0x00; +static constexpr uint8_t REG_TEMPERATURE = 0x06; // 0.1 K +static constexpr uint8_t REG_VOLTAGE = 0x08; // mV +static constexpr uint8_t REG_CURRENT = 0x0C; // mA, signed +static constexpr uint8_t REG_REMAINING_CAPACITY = 0x10; // mAh +static constexpr uint8_t REG_FULL_CHARGE_CAPACITY = 0x12; // mAh +static constexpr uint8_t REG_TIME_TO_EMPTY = 0x16; // min, 0xFFFF when not discharging +static constexpr uint8_t REG_STATE_OF_CHARGE = 0x2C; // % +static constexpr uint8_t REG_STATE_OF_HEALTH = 0x2E; // % +// The DEVICE_NUMBER subcommand (0x0001, SLUUBD4A §2.2.2) answers through the MAC data buffer +static constexpr uint8_t REG_MAC_DATA = 0x40; +static constexpr uint16_t DEVICE_NUMBER = 0x0220; +static constexpr uint16_t TIME_TO_EMPTY_NOT_APPLICABLE = 0xFFFF; + +bool BQ27220Component::read_word_(uint8_t reg, uint16_t &value) { + uint8_t data[2]; + if (this->read_register(reg, data, 2) != i2c::ERROR_OK) { + return false; + } + value = encode_uint16(data[1], data[0]); + return true; +} + +void BQ27220Component::setup() { + const uint8_t device_number_cmd[2] = {0x01, 0x00}; // DEVICE_NUMBER subcommand, little-endian + if (this->write_register(REG_CONTROL, device_number_cmd, 2) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to communicate with BQ27220")); + return; + } + delay(2); // SLUUBD4A §5.3: the subcommand result needs a moment before it can be read + uint16_t device_number = 0; + if (!this->read_word_(REG_MAC_DATA, device_number)) { + this->mark_failed(LOG_STR("Failed to read device number from BQ27220")); + return; + } + if (device_number != DEVICE_NUMBER) { + ESP_LOGE(TAG, "Unexpected device number 0x%04X (expected 0x%04X)", device_number, DEVICE_NUMBER); + this->mark_failed(); + } +} + +void BQ27220Component::update() { + bool ok = true; + this->publish_(this->voltage_sensor_, REG_VOLTAGE, ok, [](uint16_t raw) { return raw * 0.001f; }); + this->publish_(this->current_sensor_, REG_CURRENT, ok, + [](uint16_t raw) { return static_cast(raw) * 0.001f; }); + this->publish_(this->battery_level_sensor_, REG_STATE_OF_CHARGE, ok, [](uint16_t raw) { return float(raw); }); + this->publish_(this->temperature_sensor_, REG_TEMPERATURE, ok, [](uint16_t raw) { return raw * 0.1f - 273.15f; }); + this->publish_(this->remaining_capacity_sensor_, REG_REMAINING_CAPACITY, ok, [](uint16_t raw) { return float(raw); }); + this->publish_(this->full_charge_capacity_sensor_, REG_FULL_CHARGE_CAPACITY, ok, + [](uint16_t raw) { return float(raw); }); + this->publish_(this->time_to_empty_sensor_, REG_TIME_TO_EMPTY, ok, + [](uint16_t raw) { return raw == TIME_TO_EMPTY_NOT_APPLICABLE ? NAN : float(raw); }); + this->publish_(this->state_of_health_sensor_, REG_STATE_OF_HEALTH, ok, [](uint16_t raw) { return float(raw); }); + + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR("Failed to read one or more registers from BQ27220")); + } +} + +void BQ27220Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ27220 Battery Fuel Gauge:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Remaining Capacity", this->remaining_capacity_sensor_); + LOG_SENSOR(" ", "Full Charge Capacity", this->full_charge_capacity_sensor_); + LOG_SENSOR(" ", "Time To Empty", this->time_to_empty_sensor_); + LOG_SENSOR(" ", "State Of Health", this->state_of_health_sensor_); +} + +} // namespace esphome::bq27220 diff --git a/esphome/components/bq27220/bq27220.h b/esphome/components/bq27220/bq27220.h new file mode 100644 index 0000000000..d88b76a20f --- /dev/null +++ b/esphome/components/bq27220/bq27220.h @@ -0,0 +1,44 @@ +#pragma once + +// Datasheet: https://www.ti.com/lit/ds/symlink/bq27220.pdf +// Technical Reference Manual: https://www.ti.com/lit/ug/sluubd4a/sluubd4a.pdf + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::bq27220 { + +class BQ27220Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(voltage) + SUB_SENSOR(current) + SUB_SENSOR(battery_level) + SUB_SENSOR(temperature) + SUB_SENSOR(remaining_capacity) + SUB_SENSOR(full_charge_capacity) + SUB_SENSOR(time_to_empty) + SUB_SENSOR(state_of_health) + + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + bool read_word_(uint8_t reg, uint16_t &value); + /// Read a standard command register and publish it through the sensor, if one is configured. + /// A failed read publishes NAN and clears `ok`. + template void publish_(sensor::Sensor *sensor, uint8_t reg, bool &ok, F convert) { + if (sensor == nullptr) + return; + uint16_t raw; + if (!this->read_word_(reg, raw)) { + ok = false; + sensor->publish_state(NAN); + return; + } + sensor->publish_state(convert(raw)); + } +}; + +} // namespace esphome::bq27220 diff --git a/esphome/components/bq27220/sensor.py b/esphome/components/bq27220/sensor.py new file mode 100644 index 0000000000..a3fcec2243 --- /dev/null +++ b/esphome/components/bq27220/sensor.py @@ -0,0 +1,123 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import UNIT_MILLIAMPERE_HOUR +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_CURRENT, + CONF_ID, + CONF_TEMPERATURE, + CONF_VOLTAGE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_BATTERY, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_MINUTE, + UNIT_PERCENT, + UNIT_VOLT, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +bq27220_ns = cg.esphome_ns.namespace("bq27220") +BQ27220Component = bq27220_ns.class_( + "BQ27220Component", cg.PollingComponent, i2c.I2CDevice +) + +CONF_REMAINING_CAPACITY = "remaining_capacity" +CONF_FULL_CHARGE_CAPACITY = "full_charge_capacity" +CONF_TIME_TO_EMPTY = "time_to_empty" +CONF_STATE_OF_HEALTH = "state_of_health" + +# Other SLUUBD4A standard commands are intentionally not exposed to keep the +# component focused; they can be added later if there is demand: TimeToFull +# (0x18), CycleCount (0x2A), BatteryStatus (0x0A), AveragePower (0x24), +# DesignCapacity (0x3C). + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ27220Component), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_REMAINING_CAPACITY): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIAMPERE_HOUR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_FULL_CHARGE_CAPACITY): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIAMPERE_HOUR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_TIME_TO_EMPTY): sensor.sensor_schema( + unit_of_measurement=UNIT_MINUTE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_STATE_OF_HEALTH): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x55)) +) + + +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) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_REMAINING_CAPACITY, var.set_remaining_capacity_sensor) + await sensors(CONF_FULL_CHARGE_CAPACITY, var.set_full_charge_capacity_sensor) + await sensors(CONF_TIME_TO_EMPTY, var.set_time_to_empty_sensor) + await sensors(CONF_STATE_OF_HEALTH, var.set_state_of_health_sensor) diff --git a/tests/components/bq27220/common.yaml b/tests/components/bq27220/common.yaml new file mode 100644 index 0000000000..99d1e1f7f8 --- /dev/null +++ b/tests/components/bq27220/common.yaml @@ -0,0 +1,19 @@ +sensor: + - platform: bq27220 + id: bq27220_id + voltage: + name: "Battery Voltage" + current: + name: "Battery Current" + battery_level: + name: "Battery" + temperature: + name: "Battery Temperature" + remaining_capacity: + name: "Battery Remaining" + full_charge_capacity: + name: "Battery Full Capacity" + time_to_empty: + name: "Time to Empty" + state_of_health: + name: "Battery Health" diff --git a/tests/components/bq27220/test.esp32-idf.yaml b/tests/components/bq27220/test.esp32-idf.yaml new file mode 100644 index 0000000000..fe4dd41cef --- /dev/null +++ b/tests/components/bq27220/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq27220: !include common.yaml From 3ddbf8b3efd588601ea3eda75f5b302c15013270 Mon Sep 17 00:00:00 2001 From: Dennis Gaida <2392217+DennisGaida@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:12:11 +0200 Subject: [PATCH 217/268] [lp5562] Add LP5562 4-channel I2C LED driver output component (#18453) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/lp5562/__init__.py | 29 ++++++++ esphome/components/lp5562/lp5562_output.cpp | 72 +++++++++++++++++++ esphome/components/lp5562/lp5562_output.h | 35 +++++++++ esphome/components/lp5562/output.py | 35 +++++++++ tests/components/lp5562/common.yaml | 21 ++++++ tests/components/lp5562/test.esp32-idf.yaml | 4 ++ tests/components/lp5562/test.esp8266-ard.yaml | 4 ++ tests/components/lp5562/test.rp2040-ard.yaml | 4 ++ 9 files changed, 205 insertions(+) create mode 100644 esphome/components/lp5562/__init__.py create mode 100644 esphome/components/lp5562/lp5562_output.cpp create mode 100644 esphome/components/lp5562/lp5562_output.h create mode 100644 esphome/components/lp5562/output.py create mode 100644 tests/components/lp5562/common.yaml create mode 100644 tests/components/lp5562/test.esp32-idf.yaml create mode 100644 tests/components/lp5562/test.esp8266-ard.yaml create mode 100644 tests/components/lp5562/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 87a89a14f8..09fdec59a0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -331,6 +331,7 @@ esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow +esphome/components/lp5562/* @DennisGaida esphome/components/lps22/* @nagisa esphome/components/lsm6ds/* @clydebarrow esphome/components/ltr390/* @latonita @sjtrny diff --git a/esphome/components/lp5562/__init__.py b/esphome/components/lp5562/__init__.py new file mode 100644 index 0000000000..b8ace97fc9 --- /dev/null +++ b/esphome/components/lp5562/__init__.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@DennisGaida"] +DEPENDENCIES = ["i2c"] +DOMAIN = "lp5562" +MULTI_CONF = True + +lp5562_ns = cg.esphome_ns.namespace("lp5562") +LP5562Output = lp5562_ns.class_("LP5562Output", cg.Component, i2c.I2CDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LP5562Output), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x30)) +) + + +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) diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp new file mode 100644 index 0000000000..356ef2c066 --- /dev/null +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -0,0 +1,72 @@ +#include "lp5562_output.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lp5562 { + +ESPHOME_LOG_TAG(TAG, "lp5562"); + +// see table 26 in the datasheet https://web.archive.org/web/20240327023921/https://www.ti.com/lit/ds/symlink/lp5562.pdf +static constexpr uint8_t LP5562_REG_ENABLE = 0x00; +static constexpr uint8_t LP5562_REG_CONFIG = 0x08; +static constexpr uint8_t LP5562_REG_RESET = 0x0d; +static constexpr uint8_t LP5562_REG_LED_MAP = 0x70; + +static constexpr uint8_t LP5562_RESET_VALUE = 0xff; + +static constexpr uint8_t LP5562_ENABLE_CHIP_EN = 0x40; +static constexpr uint8_t LP5562_CONFIG_INT_CLK_EN = 0x01; +static constexpr uint8_t LP5562_LED_MAP_ALL_PWM_REGISTERS = 0x00; // every channel driven by its PWM register + +// PWM duty-cycle registers, indexed the same way as the LP5562_CHANNELS enum in output.py +static const uint8_t LP5562_REG_PWM[4] PROGMEM = { + 0x02, // blue + 0x03, // green + 0x04, // red + 0x0e, // white +}; + +void LP5562Output::setup() { + // clear any state left over from before a warm reboot; the chip may not ACK while resetting, so ignore the result + this->write_byte(LP5562_REG_RESET, LP5562_RESET_VALUE); + delay(1); + // power up the chip and enable the internal 32 kHz oscillator, see + // https://github.com/m5stack/M5GFX/blob/b2422cdb4735159209c2097d425c0756505b9a81/src/M5GFX.cpp#L566 + if (!this->write_byte(LP5562_REG_ENABLE, LP5562_ENABLE_CHIP_EN)) { + this->mark_failed(); + return; + } + delay(1); + // use the internal clock, and drive every channel directly from its PWM register instead of the + // internal blink/fade engines + if (!this->write_byte(LP5562_REG_CONFIG, LP5562_CONFIG_INT_CLK_EN) || + !this->write_byte(LP5562_REG_LED_MAP, LP5562_LED_MAP_ALL_PWM_REGISTERS)) { + this->mark_failed(); + } +} + +void LP5562Output::dump_config() { + ESP_LOGCONFIG(TAG, "LP5562:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, "Setting up LP5562 failed!"); + } +} + +void LP5562Output::set_channel_value_(uint8_t channel, uint8_t value) { + if (this->is_failed()) + return; + if (!this->write_byte(progmem_read_byte(&LP5562_REG_PWM[channel]), value)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); +} + +void LP5562Channel::write_state(float state) { + const uint8_t max_duty = 255; + const auto duty = static_cast(roundf(state * max_duty)); + this->parent_->set_channel_value_(this->channel_, duty); +} + +} // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/lp5562_output.h b/esphome/components/lp5562/lp5562_output.h new file mode 100644 index 0000000000..a67a4b0658 --- /dev/null +++ b/esphome/components/lp5562/lp5562_output.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/output/float_output.h" + +namespace esphome::lp5562 { + +class LP5562Output; + +class LP5562Channel final : public output::FloatOutput, public Parented { + public: + void set_channel(uint8_t channel) { this->channel_ = channel; } + + protected: + void write_state(float state) override; + + uint8_t channel_{0}; +}; + +/// LP5562 4-channel (RGBW) I2C LED driver. +class LP5562Output final : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + friend class LP5562Channel; + + void set_channel_value_(uint8_t channel, uint8_t value); +}; + +} // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/output.py b/esphome/components/lp5562/output.py new file mode 100644 index 0000000000..4866eb7b9c --- /dev/null +++ b/esphome/components/lp5562/output.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType + +from . import LP5562Output, lp5562_ns + +DEPENDENCIES = ["lp5562"] + +LP5562Channel = lp5562_ns.class_("LP5562Channel", output.FloatOutput) +CONF_LP5562_ID = "lp5562_id" + +# Indices match the LP5562_REG_PWM lookup table in lp5562_output.cpp +LP5562_CHANNELS = { + "blue": 0, + "green": 1, + "red": 2, + "white": 3, +} + +CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(LP5562Channel), + cv.GenerateID(CONF_LP5562_ID): cv.use_id(LP5562Output), + cv.Required(CONF_CHANNEL): cv.enum(LP5562_CHANNELS, lower=True), + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_LP5562_ID]) + cg.add(var.set_channel(config[CONF_CHANNEL])) + await output.register_output(var, config) diff --git a/tests/components/lp5562/common.yaml b/tests/components/lp5562/common.yaml new file mode 100644 index 0000000000..c1608f1a3c --- /dev/null +++ b/tests/components/lp5562/common.yaml @@ -0,0 +1,21 @@ +lp5562: + - address: 0x30 + id: lp5562_1 + +output: + - platform: lp5562 + id: lp5562_red + channel: red + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_green + channel: green + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_blue + channel: blue + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_white + channel: white + lp5562_id: lp5562_1 diff --git a/tests/components/lp5562/test.esp32-idf.yaml b/tests/components/lp5562/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/lp5562/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/lp5562/test.esp8266-ard.yaml b/tests/components/lp5562/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/lp5562/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/lp5562/test.rp2040-ard.yaml b/tests/components/lp5562/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/lp5562/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From a68cf669aab3d4554a31b29ce25f0c5199e5f37a Mon Sep 17 00:00:00 2001 From: Zebble Date: Mon, 12 Oct 2026 18:39:11 -0400 Subject: [PATCH 218/268] [tca8418] Add TCA8418 keypad component (#18521) Co-authored-by: Claude Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/tca8418/__init__.py | 156 ++++++++++++++++ .../tca8418/binary_sensor/__init__.py | 143 +++++++++++++++ .../binary_sensor/tca8418_binary_sensor.h | 26 +++ esphome/components/tca8418/tca8418.cpp | 170 ++++++++++++++++++ esphome/components/tca8418/tca8418.h | 85 +++++++++ esphome/core/defines.h | 1 + tests/component_tests/tca8418/__init__.py | 0 .../tca8418/test_final_validate.py | 156 ++++++++++++++++ .../component_tests/tca8418/test_validate.py | 142 +++++++++++++++ tests/components/tca8418/common.yaml | 36 ++++ tests/components/tca8418/test.esp32-idf.yaml | 6 + .../components/tca8418/test.esp8266-ard.yaml | 6 + 13 files changed, 928 insertions(+) create mode 100644 esphome/components/tca8418/__init__.py create mode 100644 esphome/components/tca8418/binary_sensor/__init__.py create mode 100644 esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h create mode 100644 esphome/components/tca8418/tca8418.cpp create mode 100644 esphome/components/tca8418/tca8418.h create mode 100644 tests/component_tests/tca8418/__init__.py create mode 100644 tests/component_tests/tca8418/test_final_validate.py create mode 100644 tests/component_tests/tca8418/test_validate.py create mode 100644 tests/components/tca8418/common.yaml create mode 100644 tests/components/tca8418/test.esp32-idf.yaml create mode 100644 tests/components/tca8418/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 09fdec59a0..ea3cdaed29 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -581,6 +581,7 @@ esphome/components/t6615/* @tylermenezes esphome/components/tas2780/* @remcom esphome/components/tas58xx/* @mrtoy-me @remcom esphome/components/tc74/* @sethgirvan +esphome/components/tca8418/* @zebble esphome/components/tca9548a/* @andreashergert1984 esphome/components/tca9555/* @mobrembski esphome/components/tcl112/* @glmnet diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py new file mode 100644 index 0000000000..67b38dc21e --- /dev/null +++ b/esphome/components/tca8418/__init__.py @@ -0,0 +1,156 @@ +import logging + +from esphome import automation +import esphome.codegen as cg +from esphome.components import gpio_expander, i2c, key_provider +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_KEY +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@zebble"] +DEPENDENCIES = ["i2c"] +DOMAIN = "tca8418" +AUTO_LOAD = ["key_provider", "gpio_expander"] +MULTI_CONF = True + +# Each keypad keeps its listeners in a fixed size list; the define is the largest count any one keypad needs +_request_listener_slot = cg.slot_counter("TCA8418_LISTENER_COUNT") + + +async def register_listener(keypad: cg.MockObj, var: cg.MockObj) -> None: + _request_listener_slot(str(keypad)) + cg.add(keypad.register_listener(var)) + + +CONF_GPI_EVENTS = "gpi_events" +CONF_KEYPAD_ID = "keypad_id" + +# Matrix keys are numbered row by row from 1 with ten columns per row; pins outside +# the matrix report as individual inputs 97..114 (ROW0..7, then COL0..9) +MATRIX_COLUMNS = 10 +MATRIX_KEY_MAX = 80 +GPI_KEY_MIN = 97 +GPI_KEY_MAX = 114 + + +def _matrix_key(row: int, col: int) -> int: + return row * MATRIX_COLUMNS + col + 1 + + +tca8418_ns = cg.esphome_ns.namespace("tca8418") +TCA8418Component = tca8418_ns.class_( + "TCA8418Component", key_provider.KeyProvider, cg.Component, i2c.I2CDevice +) + + +def _ascii_string(value: str) -> str: + """A string of single byte characters, so it lines up with the matrix.""" + value = cv.string_strict(value) + if not value.isascii(): + raise cv.Invalid( + "Only ASCII characters can be used, since each key is one character" + ) + return value + + +def _validate(config: ConfigType) -> ConfigType: + rows = config.get(CONF_ROWS, 0) + columns = config.get(CONF_COLUMNS, 0) + if (keys := config.get(CONF_KEYS)) is not None: + if rows == 0: + raise cv.Invalid( + "'keys' maps the key matrix, so 'rows' and 'columns' are required", + path=[CONF_KEYS], + ) + if len(keys) != rows * columns: + raise cv.Invalid( + f"'keys' must have exactly {rows * columns} characters " + f"({rows} rows x {columns} columns), got {len(keys)}", + path=[CONF_KEYS], + ) + # Individual inputs report as 97..114, which are also the codes for the + # letters 'a' to 'r'. Both reach on_key and key collectors as a number. + if ( + keys is not None + and config[CONF_GPI_EVENTS] + and ( + clashing := sorted( + {k for k in keys if GPI_KEY_MIN <= ord(k) <= GPI_KEY_MAX} + ) + ) + ): + _LOGGER.warning( + "In '%s', the key(s) %s cannot be told apart from an individual input " + "on the '%s' and key collector paths, because both are reported as the " + "same number. Binary sensors are unaffected. Use other characters, or " + "set '%s: false', if this matters.", + CONF_KEYS, + ", ".join(f"'{k}'" for k in clashing), + CONF_ON_KEY, + CONF_GPI_EVENTS, + ) + if rows == 0 and not config[CONF_GPI_EVENTS]: + raise cv.Invalid( + "Nothing to report: set 'rows' and 'columns' for a key matrix, " + "or leave 'gpi_events' enabled to report individual inputs" + ) + return config + + +_MATRIX_MSG = ( + "'rows' and 'columns' must both be set to use the key matrix, " + "or both left unset to use every pin as an individual input" +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TCA8418Component), + # The key matrix occupies ROW0..ROW(rows-1) and COL0..COL(columns-1). + # Leave both unset to use every pin as an individual input instead. + cv.Inclusive(CONF_ROWS, "matrix", msg=_MATRIX_MSG): cv.int_range( + min=1, max=8 + ), + cv.Inclusive(CONF_COLUMNS, "matrix", msg=_MATRIX_MSG): cv.int_range( + min=1, max=10 + ), + cv.Optional(CONF_KEYS): _ascii_string, + # Report pins that are not part of the matrix as individual inputs. + cv.Optional(CONF_GPI_EVENTS, default=True): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, + cv.Optional(CONF_ON_KEY): automation.validate_automation({}), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x34)), + _validate, +) + + +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) + + if (rows := config.get(CONF_ROWS)) is not None: + columns = config[CONF_COLUMNS] + cg.add(var.set_rows(rows)) + cg.add(var.set_columns(columns)) + if (keys := config.get(CONF_KEYS)) is not None: + # Indexed by key number - 1, with 0 where no key is assigned. + table = [0] * _matrix_key(rows - 1, columns - 1) + for index, char in enumerate(keys.encode()): + table[_matrix_key(*divmod(index, columns)) - 1] = char + array = cg.shared_progmem_array("tca8418_keys", cg.uint8, table) + cg.add(var.set_keys(array, len(table))) + cg.add(var.set_gpi_events(config[CONF_GPI_EVENTS])) + if (interrupt_pin := config.get(CONF_INTERRUPT_PIN)) is not None: + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + for conf in config.get(CONF_ON_KEY, []): + await automation.build_callback_automation( + var, "add_on_key_callback", [(cg.uint8, "x")], conf + ) diff --git a/esphome/components/tca8418/binary_sensor/__init__.py b/esphome/components/tca8418/binary_sensor/__init__.py new file mode 100644 index 0000000000..3e1e766c57 --- /dev/null +++ b/esphome/components/tca8418/binary_sensor/__init__.py @@ -0,0 +1,143 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_COL, CONF_KEY, CONF_ROW +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import ( + CONF_GPI_EVENTS, + CONF_KEYPAD_ID, + GPI_KEY_MAX, + GPI_KEY_MIN, + MATRIX_COLUMNS, + MATRIX_KEY_MAX, + TCA8418Component, + _ascii_string, + _matrix_key, + register_listener, + tca8418_ns, +) + +DEPENDENCIES = ["tca8418"] + +CONF_KEY_CODE = "key_code" + +TCA8418BinarySensor = tca8418_ns.class_( + "TCA8418BinarySensor", binary_sensor.BinarySensorInitiallyOff +) + + +def _key_code(value: int) -> int: + value = cv.int_(value) + if 1 <= value <= MATRIX_KEY_MAX or GPI_KEY_MIN <= value <= GPI_KEY_MAX: + return value + raise cv.Invalid( + f"Key numbers run from 1 to {MATRIX_KEY_MAX} for the key matrix, " + f"and {GPI_KEY_MIN} to {GPI_KEY_MAX} for individual inputs" + ) + + +_POSITION_MSG = f"'{CONF_ROW}' and '{CONF_COL}' must be given together" + +CONFIG_SCHEMA = cv.All( + binary_sensor.binary_sensor_schema(TCA8418BinarySensor).extend( + { + cv.GenerateID(CONF_KEYPAD_ID): cv.use_id(TCA8418Component), + cv.Inclusive(CONF_ROW, "position", msg=_POSITION_MSG): cv.int_range( + min=0, max=7 + ), + cv.Inclusive(CONF_COL, "position", msg=_POSITION_MSG): cv.int_range( + min=0, max=9 + ), + cv.Optional(CONF_KEY): cv.All( + cv.string, + _ascii_string, + cv.Length(min=1, max=1, msg="Key must be a single character"), + ), + cv.Optional(CONF_KEY_CODE): _key_code, + } + ), + cv.has_exactly_one_key(CONF_ROW, CONF_KEY, CONF_KEY_CODE), +) + + +def _final_validate(config: ConfigType) -> None: + """Resolve the key to the number the device reports, checked against the keypad's matrix.""" + full_config = fv.full_config.get() + keypad_path = full_config.get_path_for_id(config[CONF_KEYPAD_ID])[:-1] + keypad = full_config.get_config_for_path(keypad_path) + rows = keypad.get(CONF_ROWS, 0) + columns = keypad.get(CONF_COLUMNS, 0) + + if (row := config.get(CONF_ROW)) is not None: + col = config[CONF_COL] + if row >= rows or col >= columns: + raise cv.Invalid( + f"Position {row}/{col} is outside the {rows} x {columns} key " + "matrix of the keypad it belongs to", + path=[CONF_ROW], + ) + config[CONF_KEY_CODE] = _matrix_key(row, col) + return + + if (key_code := config.get(CONF_KEY_CODE)) is not None: + if key_code <= MATRIX_KEY_MAX: + row, col = divmod(key_code - 1, MATRIX_COLUMNS) + if row >= rows or col >= columns: + raise cv.Invalid( + f"Key number {key_code} is position {row}/{col}, which is " + f"outside the {rows} x {columns} key matrix of the keypad it " + "belongs to", + path=[CONF_KEY_CODE], + ) + return + if not keypad[CONF_GPI_EVENTS]: + raise cv.Invalid( + f"Key number {key_code} is an individual input, but the keypad has " + f"'{CONF_GPI_EVENTS}' turned off", + path=[CONF_KEY_CODE], + ) + # 97..104 are ROW0..ROW7 and 105..114 are COL0..COL9. A pin used by the + # matrix cannot also report on its own. + offset = key_code - GPI_KEY_MIN + if offset < 8: + in_matrix, pin = offset < rows, f"ROW{offset}" + else: + in_matrix, pin = offset - 8 < columns, f"COL{offset - 8}" + if in_matrix: + raise cv.Invalid( + f"Key number {key_code} is {pin}, which the key matrix is using, so " + "it cannot report as an individual input", + path=[CONF_KEY_CODE], + ) + return + + if (key := config.get(CONF_KEY)) is not None: + keys = keypad.get(CONF_KEYS) + if keys is None: + raise cv.Invalid( + f"'{CONF_KEY}' names a key from the keypad's 'keys', which is not set", + path=[CONF_KEY], + ) + if (index := keys.find(key)) < 0: + raise cv.Invalid( + f"'{key}' is not one of the keypad's keys", path=[CONF_KEY] + ) + if keys.count(key) > 1: + raise cv.Invalid( + f"'{key}' appears more than once in the keypad's keys, so it does " + f"not name one key. Use '{CONF_ROW}' and '{CONF_COL}' instead", + path=[CONF_KEY], + ) + config[CONF_KEY_CODE] = _matrix_key(*divmod(index, columns)) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = await binary_sensor.new_binary_sensor(config, config[CONF_KEY_CODE]) + keypad = await cg.get_variable(config[CONF_KEYPAD_ID]) + await register_listener(keypad, var) diff --git a/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h new file mode 100644 index 0000000000..d6d58b93bc --- /dev/null +++ b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "../tca8418.h" + +namespace esphome::tca8418 { + +/// One key of a TCA8418, matched by the key number codegen resolved from the config +class TCA8418BinarySensor : public binary_sensor::BinarySensorInitiallyOff, public TCA8418Listener { + public: + explicit TCA8418BinarySensor(uint8_t key) : key_(key) {} + + void key_pressed(uint8_t key) override { + if (key == this->key_) + this->publish_state(true); + } + void key_released(uint8_t key) override { + if (key == this->key_) + this->publish_state(false); + } + + protected: + const uint8_t key_; +}; + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp new file mode 100644 index 0000000000..7add67e9b0 --- /dev/null +++ b/esphome/components/tca8418/tca8418.cpp @@ -0,0 +1,170 @@ +#include "tca8418.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::tca8418 { + +ESPHOME_LOG_TAG(TAG, "tca8418"); + +// Minimum gap between event queue reads; the ten entry queue holds anything arriving sooner. +static constexpr uint32_t POLL_INTERVAL_MS = 50; + +void IRAM_ATTR TCA8418Component::interrupt_handler(TCA8418Component *arg) { arg->enable_loop_soon_any_context(); } + +void TCA8418Component::setup() { + // The configuration register reads back, so use it to check the device is there. + uint8_t config; + if (!this->read_byte(TCA8418_REG_CFG, &config)) { + ESP_LOGE(TAG, "Failed to read from device - check wiring / address"); + this->mark_failed(); + return; + } + + if (!this->configure_pins_()) { + ESP_LOGE(TAG, "Failed to configure pins"); + this->mark_failed(); + return; + } + + // Report key events, and keep the interrupt asserted while events remain queued. + if (!this->write_byte(TCA8418_REG_CFG, TCA8418_CFG_KEY_INT_EN | TCA8418_CFG_INT_CFG)) { + ESP_LOGE(TAG, "Failed to write the configuration register"); + this->mark_failed(); + return; + } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // The interrupt output is active low, so a falling edge means an event was + // queued. The handler only wakes the loop; the queue is read from there. + this->interrupt_pin_->attach_interrupt(&TCA8418Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE); + } + + // Discard anything the device queued before it was configured, and clear + // every interrupt flag: leaving one set holds the interrupt output asserted. + if (!this->drain_events_(false)) { + ESP_LOGE(TAG, "Failed to clear the event queue"); + this->mark_failed(); + return; + } + + // With an interrupt pin there is nothing to do until it fires. Stay running + // if the device still has something queued, since that produces no new edge. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +bool TCA8418Component::configure_pins_() { + // Each of the three registers covers a group of pins: ROW0-7, COL0-7, COL8-9. + uint8_t matrix[3] = {0, 0, 0}; + if (this->rows_ > 0) { + matrix[0] = static_cast((1U << this->rows_) - 1U); + const uint8_t low_columns = this->columns_ > 8 ? 8 : this->columns_; + matrix[1] = static_cast((1U << low_columns) - 1U); + if (this->columns_ > 8) + matrix[2] = static_cast((1U << (this->columns_ - 8U)) - 1U); + } + + // Pins outside the matrix report individually when they are in event mode. + // The device ignores these bits for pins that belong to the matrix. + const uint8_t gpi[3] = { + static_cast(this->gpi_events_ ? ~matrix[0] : 0), + static_cast(this->gpi_events_ ? ~matrix[1] : 0), + static_cast(this->gpi_events_ ? (0x03 & ~matrix[2]) : 0), + }; + + for (uint8_t i = 0; i < 3; i++) { + // Enable the internal pull-ups (a 0 bit enables the pull-up) so that + // buttons can simply switch their pin to ground. + if (!this->write_byte(TCA8418_REG_GPIO_PULL1 + i, 0x00) || !this->write_byte(TCA8418_REG_KP_GPIO1 + i, matrix[i]) || + !this->write_byte(TCA8418_REG_GPI_EM1 + i, gpi[i]) || !this->write_byte(TCA8418_REG_GPIO_INT_EN1 + i, gpi[i])) { + return false; + } + } + return true; +} + +void TCA8418Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ < POLL_INTERVAL_MS) + return; + this->last_poll_ = now; + + if (!this->drain_events_(true)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + + // Events that arrive during a read keep INT asserted without a new edge, so only stop once it is clear + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +bool TCA8418Component::drain_events_(bool dispatch) { + uint8_t count; + if (!this->read_byte(TCA8418_REG_KEY_LCK_EC, &count)) + return false; + count &= TCA8418_EVENT_COUNT_MASK; + // An idle poll has no flags to clear, unless INT is still asserted from a failed clear + if (count == 0 && dispatch && (this->interrupt_pin_ == nullptr || this->interrupt_pin_->digital_read())) + return true; + // The overflow flag stays clear with a full queue, so check the count + if (dispatch && count >= TCA8418_FIFO_DEPTH) { + ESP_LOGW(TAG, "Event queue is full - some key presses may have been lost"); + } + + uint8_t event; + for (uint8_t i = 0; i < count; i++) { + if (!this->read_byte(TCA8418_REG_KEY_EVENT_A, &event)) + return false; + if (dispatch) + this->dispatch_(event & TCA8418_KEY_CODE_MASK, (event & TCA8418_KEY_PRESSED) != 0); + } + return this->write_byte(TCA8418_REG_INT_STAT, TCA8418_INT_STAT_ALL); +} + +uint8_t TCA8418Component::key_char_(uint8_t key) const { + if (key == 0 || key > this->keys_size_) + return 0; + return progmem_read_byte(&this->keys_[key - 1]); +} + +void TCA8418Component::dispatch_(uint8_t key, bool pressed) { + ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released")); + +#ifdef TCA8418_LISTENER_COUNT + for (auto *listener : this->listeners_) { + if (pressed) { + listener->key_pressed(key); + } else { + listener->key_released(key); + } + } +#endif + + if (!pressed) + return; + + // Key collectors and automations receive the mapped character when a key map + // is configured, and the key number otherwise. + const uint8_t key_char = this->key_char_(key); + this->send_key_(key_char != 0 ? key_char : key); +} + +void TCA8418Component::dump_config() { + ESP_LOGCONFIG(TAG, + "TCA8418:\n" + " Individual inputs: %s", + YESNO(this->gpi_events_)); + if (this->rows_ > 0) { + ESP_LOGCONFIG(TAG, " Key matrix: %u rows x %u columns", this->rows_, this->columns_); + } + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.h b/esphome/components/tca8418/tca8418.h new file mode 100644 index 0000000000..383182c1e0 --- /dev/null +++ b/esphome/components/tca8418/tca8418.h @@ -0,0 +1,85 @@ +#pragma once + +#include "esphome/components/key_provider/key_provider.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tca8418 { + +// Register map (see TI datasheet SCPS231) +static constexpr uint8_t TCA8418_REG_CFG = 0x01; +static constexpr uint8_t TCA8418_REG_INT_STAT = 0x02; +static constexpr uint8_t TCA8418_REG_KEY_LCK_EC = 0x03; +static constexpr uint8_t TCA8418_REG_KEY_EVENT_A = 0x04; // top of the event FIFO +static constexpr uint8_t TCA8418_REG_GPIO_INT_EN1 = 0x1A; +static constexpr uint8_t TCA8418_REG_KP_GPIO1 = 0x1D; +static constexpr uint8_t TCA8418_REG_GPI_EM1 = 0x20; +static constexpr uint8_t TCA8418_REG_GPIO_PULL1 = 0x2C; + +// CFG +static constexpr uint8_t TCA8418_CFG_KEY_INT_EN = 0x01; // interrupt on key events +static constexpr uint8_t TCA8418_CFG_INT_CFG = 0x10; // re-assert INT while events remain +// INT_STAT +static constexpr uint8_t TCA8418_INT_STAT_ALL = 0x1F; // every interrupt flag +// KEY_LCK_EC: the low nibble is the number of queued events +static constexpr uint8_t TCA8418_EVENT_COUNT_MASK = 0x0F; +// Key events: bit 7 set = pressed, clear = released; bits 6-0 are the key number +static constexpr uint8_t TCA8418_KEY_PRESSED = 0x80; +static constexpr uint8_t TCA8418_KEY_CODE_MASK = 0x7F; +// The event queue holds ten entries, which bounds how many can be read at once. +static constexpr uint8_t TCA8418_FIFO_DEPTH = 10; + +class TCA8418Listener { + public: + /// Called with the key number reported by the device (1..80 matrix, 97..114 individual inputs). + virtual void key_pressed(uint8_t key) {} + virtual void key_released(uint8_t key) {} +}; + +class TCA8418Component : public key_provider::KeyProvider, public Component, public i2c::I2CDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + + void set_rows(uint8_t rows) { this->rows_ = rows; } + void set_columns(uint8_t columns) { this->columns_ = columns; } + void set_gpi_events(bool gpi_events) { this->gpi_events_ = gpi_events; } + void set_keys(const uint8_t *keys, uint8_t size) { + this->keys_ = keys; + this->keys_size_ = size; + } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + +#ifdef TCA8418_LISTENER_COUNT + void register_listener(TCA8418Listener *listener) { this->listeners_.push_back(listener); } +#endif + + /// Wakes the loop so queued events are read. Runs in interrupt context. + static void IRAM_ATTR interrupt_handler(TCA8418Component *arg); + + protected: + /// Write the matrix / individual-input pin configuration. Returns false on I2C failure. + bool configure_pins_(); + /// Read every queued key event, dispatching them if asked, then clear the interrupt flags. + /// Returns false on I2C failure. + bool drain_events_(bool dispatch); + void dispatch_(uint8_t key, bool pressed); + /// The character mapped to a matrix key, or 0 when it has none. + uint8_t key_char_(uint8_t key) const; + + const uint8_t *keys_{nullptr}; // PROGMEM, indexed by key number - 1 + InternalGPIOPin *interrupt_pin_{nullptr}; + uint32_t last_poll_{0}; +#ifdef TCA8418_LISTENER_COUNT + StaticVector listeners_; +#endif + uint8_t rows_{0}; + uint8_t columns_{0}; + uint8_t keys_size_{0}; + bool gpi_events_{true}; +}; + +} // namespace esphome::tca8418 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a085cf9c0a..87dc703b37 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -202,6 +202,7 @@ #define SYSTA_BUS_LISTENER_COUNT 2 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 +#define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define USE_NETWORK diff --git a/tests/component_tests/tca8418/__init__.py b/tests/component_tests/tca8418/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/tca8418/test_final_validate.py b/tests/component_tests/tca8418/test_final_validate.py new file mode 100644 index 0000000000..48569f3f9e --- /dev/null +++ b/tests/component_tests/tca8418/test_final_validate.py @@ -0,0 +1,156 @@ +"""Tests for how the tca8418 binary sensor resolves its key against the keypad.""" + +from __future__ import annotations + +import pytest + +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +from esphome.components.tca8418 import CONF_GPI_EVENTS +from esphome.components.tca8418.binary_sensor import ( + CONF_KEY_CODE, + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, +) +from esphome.config import Config +import esphome.config_validation as cv +from esphome.const import ( + CONF_COL, + CONF_ID, + CONF_KEY, + CONF_NAME, + CONF_ROW, + PlatformFramework, +) +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "keypad" + + +def _full_config( + *, + rows: int = 4, + columns: int = 3, + keys: str | None = "123456789*0#", + gpi_events: bool = True, +) -> Config: + """A full config carrying one keypad, as the ID pass leaves it.""" + hub: ConfigType = { + CONF_ID: ID(HUB_ID, is_declaration=True, type="tca8418"), + CONF_ROWS: rows, + CONF_COLUMNS: columns, + CONF_GPI_EVENTS: gpi_events, + } + if keys is not None: + hub[CONF_KEYS] = keys + full = Config() + full["tca8418"] = [hub] + full.declare_ids.append((hub[CONF_ID], ["tca8418", 0, CONF_ID])) + return full + + +def _sensor(**selector: object) -> ConfigType: + config: ConfigType = { + "keypad_id": ID(HUB_ID, is_declaration=False, type="tca8418"), + CONF_NAME: "Key", + } + config.update(selector) + return config + + +def _validated(config: ConfigType) -> ConfigType: + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +@pytest.fixture +def keypad(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config()) + + +# A key number is worked out here so the sensor only compares numbers at run +# time. Matrix keys are row * 10 + column + 1. +@pytest.mark.parametrize( + ("selector", "key_code"), + [ + ({CONF_ROW: 0, CONF_COL: 0}, 1), + ({CONF_ROW: 3, CONF_COL: 2}, 33), + ({CONF_KEY: "1"}, 1), + ({CONF_KEY: "5"}, 12), + ({CONF_KEY: "#"}, 33), + ({CONF_KEY_CODE: 12}, 12), + ], +) +def test_key_is_resolved(keypad: None, selector: ConfigType, key_code: int) -> None: + assert _validated(_sensor(**selector))[CONF_KEY_CODE] == key_code + + +def test_position_outside_the_matrix_is_rejected(keypad: None) -> None: + with pytest.raises(cv.Invalid, match="outside the 4 x 3 key matrix"): + _validated(_sensor(row=0, col=3)) + + +def test_key_number_outside_the_matrix_is_rejected(keypad: None) -> None: + """Key 5 is ROW0/COL4, which a three column matrix never reports.""" + with pytest.raises(cv.Invalid, match="position 0/4, which is outside"): + _validated(_sensor(key_code=5)) + + +def test_key_number_of_a_pin_the_matrix_uses_is_rejected(keypad: None) -> None: + """97 is ROW0, which belongs to the matrix, so it cannot report on its own.""" + with pytest.raises(cv.Invalid, match="ROW0, which the key matrix is using"): + _validated(_sensor(key_code=97)) + + +def test_individual_input_outside_the_matrix_passes(keypad: None) -> None: + """ROW5 is past the four rows of the matrix, so it reports on its own.""" + assert _validated(_sensor(key_code=102))[CONF_KEY_CODE] == 102 + + +def test_column_beyond_the_matrix_passes(keypad: None) -> None: + """COL3 is past the three columns of the matrix; 105 is COL0.""" + assert _validated(_sensor(key_code=108))[CONF_KEY_CODE] == 108 + + +def test_individual_input_needs_gpi_events( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(gpi_events=False) + ) + + with pytest.raises(cv.Invalid, match="'gpi_events' turned off"): + _validated(_sensor(key_code=102)) + + +def test_character_without_a_key_map_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config(keys=None)) + + with pytest.raises(cv.Invalid, match="which is not set"): + _validated(_sensor(key="5")) + + +def test_character_not_in_the_key_map_is_rejected(keypad: None) -> None: + with pytest.raises(cv.Invalid, match="not one of the keypad's keys"): + _validated(_sensor(key="Z")) + + +def test_repeated_character_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A character appearing twice does not name one key.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(keys="1234567891*0") + ) + + with pytest.raises(cv.Invalid, match="appears more than once"): + _validated(_sensor(key="1")) + + +def test_key_given_as_a_number_is_accepted(keypad: None) -> None: + """'key: 5' is a reasonable way to name the key labelled 5.""" + assert _validated(_sensor(key=5))[CONF_KEY_CODE] == 12 diff --git a/tests/component_tests/tca8418/test_validate.py b/tests/component_tests/tca8418/test_validate.py new file mode 100644 index 0000000000..fbadd42363 --- /dev/null +++ b/tests/component_tests/tca8418/test_validate.py @@ -0,0 +1,142 @@ +"""Tests for the tca8418 keypad and binary sensor configuration schemas.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +from esphome.components.tca8418 import ( + CONF_GPI_EVENTS, + CONFIG_SCHEMA as HUB_SCHEMA, + _validate, +) +from esphome.components.tca8418.binary_sensor import ( + CONF_KEY_CODE, + CONFIG_SCHEMA as SENSOR_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import CONF_COL, CONF_ID, CONF_KEY, CONF_NAME, CONF_ROW +from esphome.core import ID +from esphome.types import ConfigType + + +def _config( + *, + rows: int = 4, + columns: int = 3, + keys: str | None = None, + gpi_events: bool = True, +) -> ConfigType: + config: ConfigType = {CONF_GPI_EVENTS: gpi_events} + if rows: + config[CONF_ROWS] = rows + if columns: + config[CONF_COLUMNS] = columns + if keys is not None: + config[CONF_KEYS] = keys + return config + + +def test_a_matrix_with_a_key_map_passes() -> None: + _validate(_config(keys="123456789*0#")) + + +def test_individual_inputs_only_passes() -> None: + """No matrix at all is fine as long as the pins report on their own.""" + _validate(_config(rows=0, columns=0)) + + +@pytest.mark.parametrize("half", [{CONF_ROWS: 4}, {CONF_COLUMNS: 3}]) +def test_half_a_matrix_is_rejected(half: ConfigType) -> None: + config = {CONF_ID: ID("k", is_declaration=True), "i2c_id": ID("bus"), **half} + with pytest.raises(cv.Invalid, match="must both be set"): + HUB_SCHEMA(config) + + +def test_key_map_without_a_matrix_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="'rows' and 'columns' are required"): + _validate(_config(rows=0, columns=0, keys="123")) + + +@pytest.mark.parametrize("keys", ["12345678901", "12345678"]) +def test_key_map_of_the_wrong_length_is_rejected(keys: str) -> None: + """A key map names every key of the matrix, so its length is fixed.""" + with pytest.raises(cv.Invalid, match="must have exactly 12 characters"): + _validate(_config(keys=keys)) + + +def test_a_keypad_that_reports_nothing_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="Nothing to report"): + _validate(_config(rows=0, columns=0, gpi_events=False)) + + +def test_letters_that_clash_with_individual_inputs_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + """97 to 114 are individual inputs and also the codes for 'a' to 'r'.""" + with caplog.at_level(logging.WARNING): + _validate(_config(keys="abc456789*0#")) + + assert "cannot be told apart" in caplog.text + assert "'a', 'b', 'c'" in caplog.text + + +def test_other_characters_do_not_warn(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + _validate(_config(keys="123456789*0#")) + + assert caplog.text == "" + + +def test_no_warning_without_individual_inputs( + caplog: pytest.LogCaptureFixture, +) -> None: + """With the individual inputs off there is nothing for a letter to clash with.""" + with caplog.at_level(logging.WARNING): + _validate(_config(keys="abc456789*0#", gpi_events=False)) + + assert caplog.text == "" + + +def _sensor(**selector: object) -> ConfigType: + return { + "keypad_id": ID("keypad", is_declaration=False, type="tca8418"), + CONF_NAME: "Key", + **selector, + } + + +# Checks made before the keypad is consulted: a key is named exactly one way, +# a position has both halves, a character is one ASCII character, and a key +# number is one the device uses (81 to 96 are unused, numbering stops at 114). +@pytest.mark.parametrize( + ("selector", "error"), + [ + ({CONF_ROW: 0, CONF_COL: 0}, None), + ({CONF_KEY: "5"}, None), + ({CONF_KEY: 5}, None), + ({CONF_KEY_CODE: 12}, None), + ({CONF_KEY_CODE: 102}, None), + ({CONF_ROW: 0, CONF_COL: 0, CONF_KEY: "5"}, "more than one of"), + ({CONF_ROW: 0, CONF_COL: 0, CONF_KEY_CODE: 12}, "more than one of"), + ({CONF_KEY: "5", CONF_KEY_CODE: 12}, "more than one of"), + ({}, "exactly one of"), + ({CONF_ROW: 0}, "must be given together"), + ({CONF_COL: 0}, "must be given together"), + ({CONF_KEY: "ab"}, "single character"), + ({CONF_KEY: ""}, "single character"), + ({CONF_KEY: "\u00e9"}, "Only ASCII characters"), + ({CONF_KEY_CODE: 0}, "Key numbers run from"), + ({CONF_KEY_CODE: 81}, "Key numbers run from"), + ({CONF_KEY_CODE: 96}, "Key numbers run from"), + ({CONF_KEY_CODE: 115}, "Key numbers run from"), + ], +) +def test_sensor_schema(selector: ConfigType, error: str | None) -> None: + if error is None: + SENSOR_SCHEMA(_sensor(**selector)) + return + with pytest.raises(cv.Invalid, match=error): + SENSOR_SCHEMA(_sensor(**selector)) diff --git a/tests/components/tca8418/common.yaml b/tests/components/tca8418/common.yaml new file mode 100644 index 0000000000..4a6b7150c4 --- /dev/null +++ b/tests/components/tca8418/common.yaml @@ -0,0 +1,36 @@ +tca8418: + # A 4x3 key matrix, with the remaining pins reporting as individual inputs. + - id: matrix_keypad_device + i2c_id: i2c_bus + address: 0x34 + rows: 4 + columns: 3 + keys: "123456789*0#" + # The interrupt output is open drain, so it needs a pull-up. Use the + # internal one unless the board already has its own. + interrupt_pin: + number: ${interrupt_pin} + mode: INPUT_PULLUP + on_key: + - logger.log: + format: "Key: %c" + args: [x] + # Every pin reporting as an individual input, polled over I2C. + - id: inputs_only_device + i2c_id: i2c_bus + address: 0x35 + +binary_sensor: + - platform: tca8418 + keypad_id: matrix_keypad_device + row: 0 + col: 0 + name: "Key top left" + - platform: tca8418 + keypad_id: matrix_keypad_device + key: "5" + name: "Key five" + - platform: tca8418 + keypad_id: inputs_only_device + key_code: 97 + name: "Input ROW0" diff --git a/tests/components/tca8418/test.esp32-idf.yaml b/tests/components/tca8418/test.esp32-idf.yaml new file mode 100644 index 0000000000..0621ab995e --- /dev/null +++ b/tests/components/tca8418/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO13 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + tca8418: !include common.yaml diff --git a/tests/components/tca8418/test.esp8266-ard.yaml b/tests/components/tca8418/test.esp8266-ard.yaml new file mode 100644 index 0000000000..9a9351cde8 --- /dev/null +++ b/tests/components/tca8418/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO12 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + tca8418: !include common.yaml From f9f41ef16e5ea8a61398ad250f483a48b39df10a Mon Sep 17 00:00:00 2001 From: JiriPrchal <163323169+JiriPrchal@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:45:08 +0200 Subject: [PATCH 219/268] [MAX31888] Add temperature sensor (#13567) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/max31888/__init__.py | 2 + esphome/components/max31888/max31888.cpp | 95 +++++++++++++++++++ esphome/components/max31888/max31888.h | 21 ++++ esphome/components/max31888/sensor.py | 36 +++++++ esphome/components/one_wire/one_wire_bus.cpp | 3 + tests/components/max31888/common.yaml | 9 ++ tests/components/max31888/test.esp32-idf.yaml | 3 + .../components/max31888/test.esp8266-ard.yaml | 3 + .../components/max31888/test.rp2040-ard.yaml | 3 + tests/test_build_components/common/README.md | 5 + .../common/one_wire/esp32-idf.yaml | 10 ++ .../common/one_wire/esp8266-ard.yaml | 10 ++ .../common/one_wire/rp2040-ard.yaml | 10 ++ 14 files changed, 211 insertions(+) create mode 100644 esphome/components/max31888/__init__.py create mode 100644 esphome/components/max31888/max31888.cpp create mode 100644 esphome/components/max31888/max31888.h create mode 100644 esphome/components/max31888/sensor.py create mode 100644 tests/components/max31888/common.yaml create mode 100644 tests/components/max31888/test.esp32-idf.yaml create mode 100644 tests/components/max31888/test.esp8266-ard.yaml create mode 100644 tests/components/max31888/test.rp2040-ard.yaml create mode 100644 tests/test_build_components/common/one_wire/esp32-idf.yaml create mode 100644 tests/test_build_components/common/one_wire/esp8266-ard.yaml create mode 100644 tests/test_build_components/common/one_wire/rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index ea3cdaed29..8c28f87977 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -343,6 +343,7 @@ esphome/components/mapping/* @clydebarrow esphome/components/matrix_keypad/* @ssieb esphome/components/max17043/* @blacknell esphome/components/max31865/* @DAVe3283 +esphome/components/max31888/* @JiriPrchal esphome/components/max44009/* @berfenger esphome/components/max6956/* @looping40 esphome/components/max7219digit/* @rspaargaren diff --git a/esphome/components/max31888/__init__.py b/esphome/components/max31888/__init__.py new file mode 100644 index 0000000000..880005d6e7 --- /dev/null +++ b/esphome/components/max31888/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@JiriPrchal"] +DOMAIN = "max31888" diff --git a/esphome/components/max31888/max31888.cpp b/esphome/components/max31888/max31888.cpp new file mode 100644 index 0000000000..5ff8e6f1ef --- /dev/null +++ b/esphome/components/max31888/max31888.cpp @@ -0,0 +1,95 @@ +#include "max31888.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::max31888 { + +ESPHOME_LOG_TAG(TAG, "max31888"); + +static constexpr uint32_t CONVERSION_TIMEOUT_ID = 0; + +static constexpr uint8_t MAX31888_COMMAND_START_CONVERSION = 0x44; +static constexpr uint8_t MAX31888_COMMAND_READ = 0x33; +static constexpr uint8_t MAX31888_COMMAND_SOFT_RESET = 0x82; +static constexpr uint8_t MAX31888_REGISTER_FIFO_DATA = 0x08; +static constexpr uint8_t MAX31888_FIFO_READ_LENGTH = 0x01; // number of bytes to read, minus one +static constexpr uint16_t MAX31888_CONVERSION_TIME_MS = 20; +static constexpr float MAX31888_DEGREES_PER_LSB = 0.005f; + +// Sends a command and checks the checksum the device appends to it (Maxim CRC-16 over the command byte, +// sent inverted and low byte first) +bool MAX31888Sensor::send_checked_command_(uint8_t command) { + if (!this->send_command_(command)) { + this->status_set_warning(LOG_STR("bus reset failed")); + return false; + } + const uint8_t low = this->bus_->read8(); + const uint8_t high = this->bus_->read8(); + if (crc16(&command, 1, 0, 0xa001, false, true) != encode_uint16(high, low)) { + this->status_set_warning(LOG_STR("command checksum invalid")); + return false; + } + return true; +} + +void MAX31888Sensor::setup() { + if (!this->check_address_or_index_()) + return; + if (!this->send_checked_command_(MAX31888_COMMAND_SOFT_RESET)) { + this->mark_failed(); + } +} + +void MAX31888Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "MAX31888 Sensor:"); + if (this->address_ == 0) { + ESP_LOGW(TAG, " Unable to select an address"); + return; + } + LOG_ONE_WIRE_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +} + +void MAX31888Sensor::update() { + if (this->address_ == 0) + return; + + if (!this->send_checked_command_(MAX31888_COMMAND_START_CONVERSION)) { + this->publish_state(NAN); + return; + } + + this->set_timeout(CONVERSION_TIMEOUT_ID, MAX31888_CONVERSION_TIME_MS, [this] { + int16_t raw; + if (!this->read_temperature_(raw)) { + this->publish_state(NAN); + return; + } + const float temperature = raw * MAX31888_DEGREES_PER_LSB; + ESP_LOGD(TAG, "'%s': Got Temperature=%.3f°C", this->get_name().c_str(), temperature); + this->status_clear_warning(); + this->publish_state(temperature); + }); +} + +bool MAX31888Sensor::read_temperature_(int16_t &raw) { + // The checksum covers the command and its two arguments as well as the data + uint8_t frame[7] = {MAX31888_COMMAND_READ, MAX31888_REGISTER_FIFO_DATA, MAX31888_FIFO_READ_LENGTH}; + if (!this->send_command_(frame[0])) { + this->status_set_warning(LOG_STR("bus reset failed")); + return false; + } + this->bus_->write8(frame[1]); + this->bus_->write8(frame[2]); + for (size_t i = 3; i < sizeof(frame); i++) { + frame[i] = this->bus_->read8(); + } + if (crc16(frame, 5, 0, 0xa001, false, true) != encode_uint16(frame[6], frame[5])) { + this->status_set_warning(LOG_STR("checksum invalid")); + return false; + } + raw = static_cast(encode_uint16(frame[3], frame[4])); + return true; +} + +} // namespace esphome::max31888 diff --git a/esphome/components/max31888/max31888.h b/esphome/components/max31888/max31888.h new file mode 100644 index 0000000000..666f301cb0 --- /dev/null +++ b/esphome/components/max31888/max31888.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/one_wire/one_wire.h" + +namespace esphome::max31888 { + +class MAX31888Sensor final : public PollingComponent, public sensor::Sensor, public one_wire::OneWireDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + bool send_checked_command_(uint8_t command); + /// Reads the latest conversion from the FIFO; false when the bus or the checksum fails + bool read_temperature_(int16_t &raw); +}; + +} // namespace esphome::max31888 diff --git a/esphome/components/max31888/sensor.py b/esphome/components/max31888/sensor.py new file mode 100644 index 0000000000..2809e7531a --- /dev/null +++ b/esphome/components/max31888/sensor.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import one_wire, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.types import ConfigType + +max31888_ns = cg.esphome_ns.namespace("max31888") + +MAX31888Sensor = max31888_ns.class_( + "MAX31888Sensor", + cg.PollingComponent, + sensor.Sensor, + one_wire.OneWireDevice, +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + MAX31888Sensor, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(one_wire.one_wire_device_schema()) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await one_wire.register_one_wire_device(var, config) diff --git a/esphome/components/one_wire/one_wire_bus.cpp b/esphome/components/one_wire/one_wire_bus.cpp index 0a4880cf73..22d3afb775 100644 --- a/esphome/components/one_wire/one_wire_bus.cpp +++ b/esphome/components/one_wire/one_wire_bus.cpp @@ -10,6 +10,7 @@ static const uint8_t DALLAS_MODEL_DS1822 = 0x22; static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_MODEL_DS1825 = 0x3B; static const uint8_t DALLAS_MODEL_DS28EA00 = 0x42; +static const uint8_t MAXIM_MODEL_MAX31888 = 0x54; const uint8_t ONE_WIRE_ROM_SELECT = 0x55; const uint8_t ONE_WIRE_ROM_SEARCH = 0xF0; @@ -76,6 +77,8 @@ const LogString *OneWireBus::get_model_str(uint8_t model) { return LOG_STR("DS1825"); case DALLAS_MODEL_DS28EA00: return LOG_STR("DS28EA00"); + case MAXIM_MODEL_MAX31888: + return LOG_STR("MAX31888"); default: return LOG_STR("Unknown"); } diff --git a/tests/components/max31888/common.yaml b/tests/components/max31888/common.yaml new file mode 100644 index 0000000000..7b9ada2e82 --- /dev/null +++ b/tests/components/max31888/common.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: max31888 + one_wire_id: one_wire_bus + address: 0x1C0000031EDD2A54 + name: MAX31888 Temperature 1 + - platform: max31888 + one_wire_id: one_wire_bus + name: MAX31888 Temperature 2 + index: 1 diff --git a/tests/components/max31888/test.esp32-idf.yaml b/tests/components/max31888/test.esp32-idf.yaml new file mode 100644 index 0000000000..943a8c54a9 --- /dev/null +++ b/tests/components/max31888/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/esp32-idf.yaml + max31888: !include common.yaml diff --git a/tests/components/max31888/test.esp8266-ard.yaml b/tests/components/max31888/test.esp8266-ard.yaml new file mode 100644 index 0000000000..29ed6be26a --- /dev/null +++ b/tests/components/max31888/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/esp8266-ard.yaml + max31888: !include common.yaml diff --git a/tests/components/max31888/test.rp2040-ard.yaml b/tests/components/max31888/test.rp2040-ard.yaml new file mode 100644 index 0000000000..80de52bae2 --- /dev/null +++ b/tests/components/max31888/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/rp2040-ard.yaml + max31888: !include common.yaml diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 010313db7f..4a89a1fb91 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -125,6 +125,11 @@ Components using `modbus` packages automatically get `uart` as well. - **RP2040**: SCL=GPIO5, SDA=GPIO4 - **BK72xx**: SCL=P20, SDA=P21 +### 1-Wire +- **ESP32 IDF**: GPIO4 +- **ESP8266**: GPIO13 +- **RP2040**: GPIO10 + ### I2C Low Frequency (10kHz) Same pin allocations as standard I2C, but with 10kHz frequency for components requiring slower speeds. diff --git a/tests/test_build_components/common/one_wire/esp32-idf.yaml b/tests/test_build_components/common/one_wire/esp32-idf.yaml new file mode 100644 index 0000000000..b34e87a758 --- /dev/null +++ b/tests/test_build_components/common/one_wire/esp32-idf.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for esp32-idf tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO4 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} diff --git a/tests/test_build_components/common/one_wire/esp8266-ard.yaml b/tests/test_build_components/common/one_wire/esp8266-ard.yaml new file mode 100644 index 0000000000..c4fefffd3e --- /dev/null +++ b/tests/test_build_components/common/one_wire/esp8266-ard.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for esp8266-ard tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO13 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} diff --git a/tests/test_build_components/common/one_wire/rp2040-ard.yaml b/tests/test_build_components/common/one_wire/rp2040-ard.yaml new file mode 100644 index 0000000000..8023264e40 --- /dev/null +++ b/tests/test_build_components/common/one_wire/rp2040-ard.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for rp2040-ard tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO10 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} From 1c315c3bf29752958ae9b22d71128bc63dc67c81 Mon Sep 17 00:00:00 2001 From: tchilov <31223839+tchilov@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:49:07 +0200 Subject: [PATCH 220/268] [ns2009] Add Nsiway NS2009 touchscreen controller component (#10597) 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 --- CODEOWNERS | 2 + esphome/components/ns2009/__init__.py | 8 +++ .../components/ns2009/touchscreen/__init__.py | 34 ++++++++++ .../components/ns2009/touchscreen/ns2009.cpp | 65 +++++++++++++++++++ .../components/ns2009/touchscreen/ns2009.h | 25 +++++++ tests/components/ns2009/common.yaml | 27 ++++++++ tests/components/ns2009/test.esp32-idf.yaml | 8 +++ tests/components/ns2009/test.esp8266-ard.yaml | 8 +++ tests/components/ns2009/test.rp2040-ard.yaml | 8 +++ 9 files changed, 185 insertions(+) create mode 100644 esphome/components/ns2009/__init__.py create mode 100644 esphome/components/ns2009/touchscreen/__init__.py create mode 100644 esphome/components/ns2009/touchscreen/ns2009.cpp create mode 100644 esphome/components/ns2009/touchscreen/ns2009.h create mode 100644 tests/components/ns2009/common.yaml create mode 100644 tests/components/ns2009/test.esp32-idf.yaml create mode 100644 tests/components/ns2009/test.esp8266-ard.yaml create mode 100644 tests/components/ns2009/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8c28f87977..45aa0c49ab 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -413,6 +413,8 @@ esphome/components/noblex/* @AGalfra esphome/components/noise/* @esphome/core esphome/components/npi19/* @bakerkj esphome/components/nrf52/* @tomaszduda23 +esphome/components/ns2009/* @tchilov +esphome/components/ns2009/touchscreen/* @tchilov esphome/components/number/* @esphome/core esphome/components/one_wire/* @ssieb esphome/components/online_image/* @clydebarrow @guillempages diff --git a/esphome/components/ns2009/__init__.py b/esphome/components/ns2009/__init__.py new file mode 100644 index 0000000000..7cd4038a95 --- /dev/null +++ b/esphome/components/ns2009/__init__.py @@ -0,0 +1,8 @@ +import esphome.config_validation as cv + +CODEOWNERS = ["@tchilov"] +DOMAIN = "ns2009" + +CONFIG_SCHEMA = cv.invalid( + "This component should be used as platform of the Touchscreen component." +) diff --git a/esphome/components/ns2009/touchscreen/__init__.py b/esphome/components/ns2009/touchscreen/__init__.py new file mode 100644 index 0000000000..49bcf7a246 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_THRESHOLD +from esphome.types import ConfigType + +CODEOWNERS = ["@tchilov"] +DEPENDENCIES = ["i2c"] + +ns2009_ns = cg.esphome_ns.namespace("ns2009") +NS2009Component = ns2009_ns.class_( + "NS2009Component", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema(calibration_required=True) + .extend( + { + cv.GenerateID(): cv.declare_id(NS2009Component), + cv.Optional(CONF_THRESHOLD, default=40): cv.uint8_t, + } + ) + .extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await i2c.register_i2c_device(var, config) + await touchscreen.register_touchscreen(var, config) + + cg.add(var.set_threshold(config[CONF_THRESHOLD])) diff --git a/esphome/components/ns2009/touchscreen/ns2009.cpp b/esphome/components/ns2009/touchscreen/ns2009.cpp new file mode 100644 index 0000000000..ae1bdfe373 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/ns2009.cpp @@ -0,0 +1,65 @@ +#include "ns2009.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ns2009 { + +ESPHOME_LOG_TAG(TAG, "ns2009"); + +static constexpr uint8_t GET_X = 0xC0; +static constexpr uint8_t GET_Y = 0xD0; +static constexpr uint8_t GET_Z = 0xE2; + +void NS2009Component::setup() { + if (!this->read_byte(GET_Z).has_value()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void NS2009Component::update_touches() { + auto data_z = this->read_byte(GET_Z); + if (!data_z.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("pressure")); + this->skip_update_ = true; + return; + } + uint8_t z = *data_z; + if (z <= this->threshold_) { + return; + } + + auto x = this->read_axis_(GET_X); + if (!x.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("X position")); + this->skip_update_ = true; + return; + } + auto y = this->read_axis_(GET_Y); + if (!y.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("Y position")); + this->skip_update_ = true; + return; + } + + ESP_LOGV(TAG, "X %4d Y %4d Z %3d", *x, *y, z); + this->add_raw_touch_position_(0, *x, *y, z); +} + +optional NS2009Component::read_axis_(uint8_t cmd) { + auto data = this->read_bytes<2>(cmd); + if (!data.has_value()) { + return {}; + } + return encode_uint16((*data)[0], (*data)[1]) >> 4; // 12 bit followed by 4 0's +} + +void NS2009Component::dump_config() { + ESP_LOGCONFIG(TAG, + "NS2009 Touchscreen:\n" + " Threshold: %u", + this->threshold_); + LOG_I2C_DEVICE(this); +} + +} // namespace esphome::ns2009 diff --git a/esphome/components/ns2009/touchscreen/ns2009.h b/esphome/components/ns2009/touchscreen/ns2009.h new file mode 100644 index 0000000000..75b675bfe9 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/ns2009.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" + +namespace esphome::ns2009 { + +class NS2009Component : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + /// Set the threshold for the touch detection. + void set_threshold(uint8_t threshold) { this->threshold_ = threshold; } + + void setup() override; + void dump_config() override; + + protected: + void update_touches() override; + /// Read a 12 bit axis value, or nothing when the read fails + optional read_axis_(uint8_t cmd); + + /// @brief The threshold at which a touch is registered. + uint8_t threshold_{40}; +}; + +} // namespace esphome::ns2009 diff --git a/tests/components/ns2009/common.yaml b/tests/components/ns2009/common.yaml new file mode 100644 index 0000000000..f2472d2d5d --- /dev/null +++ b/tests/components/ns2009/common.yaml @@ -0,0 +1,27 @@ +display: + - platform: mipi_spi + model: ILI9341 + id: ns_display + dc_pin: ${dc_pin} + cs_pin: ${cs_pin} + color_depth: 8 + invert_colors: false + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: ns2009 + id: ns_touchscreen + i2c_id: i2c_bus + display: ns_display + transform: + mirror_y: true + calibration: + x_min: 400 + x_max: 3700 + y_min: 300 + y_max: 3700 + on_touch: + - logger.log: + format: Touch at (%d, %d) + args: [touch.x, touch.y] diff --git a/tests/components/ns2009/test.esp32-idf.yaml b/tests/components/ns2009/test.esp32-idf.yaml new file mode 100644 index 0000000000..80b28696a1 --- /dev/null +++ b/tests/components/ns2009/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO15 + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + ns2009: !include common.yaml diff --git a/tests/components/ns2009/test.esp8266-ard.yaml b/tests/components/ns2009/test.esp8266-ard.yaml new file mode 100644 index 0000000000..ca78f2b029 --- /dev/null +++ b/tests/components/ns2009/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO3 + cs_pin: GPIO1 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ns2009: !include common.yaml diff --git a/tests/components/ns2009/test.rp2040-ard.yaml b/tests/components/ns2009/test.rp2040-ard.yaml new file mode 100644 index 0000000000..c10519f9d0 --- /dev/null +++ b/tests/components/ns2009/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO3 + cs_pin: GPIO1 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + ns2009: !include common.yaml From c5baae35d57b3ef185f8943bb36c7afddd049d16 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:51:48 -0400 Subject: [PATCH 221/268] [ezsp_proxy_tap] Add SiLabs EZSP auto-ACK implementation (#20304) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ezsp_proxy_tap/__init__.py | 34 ++++ .../ezsp_proxy_tap/ash_acknowledger.cpp | 126 ++++++++++++ .../ezsp_proxy_tap/ash_acknowledger.h | 54 +++++ .../ezsp_proxy_tap/ash_protocol.cpp | 19 ++ .../components/ezsp_proxy_tap/ash_protocol.h | 39 ++++ .../ezsp_proxy_tap/ezsp_proxy_tap.cpp | 38 ++++ .../ezsp_proxy_tap/ezsp_proxy_tap.h | 49 +++++ .../components/serial_proxy/serial_proxy.cpp | 14 ++ .../components/serial_proxy/serial_proxy.h | 9 + esphome/core/defines.h | 1 + .../ezsp_proxy_tap/ash_acknowledger_test.cpp | 185 ++++++++++++++++++ tests/components/ezsp_proxy_tap/common.yaml | 15 ++ .../ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml | 27 +++ .../ezsp_proxy_tap/test.esp32-idf.yaml | 3 + .../ezsp_proxy_tap/test.esp8266-ard.yaml | 3 + .../ezsp_proxy_tap/test.rp2040-ard.yaml | 3 + 17 files changed, 620 insertions(+) create mode 100644 esphome/components/ezsp_proxy_tap/__init__.py create mode 100644 esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp create mode 100644 esphome/components/ezsp_proxy_tap/ash_acknowledger.h create mode 100644 esphome/components/ezsp_proxy_tap/ash_protocol.cpp create mode 100644 esphome/components/ezsp_proxy_tap/ash_protocol.h create mode 100644 esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp create mode 100644 esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h create mode 100644 tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp create mode 100644 tests/components/ezsp_proxy_tap/common.yaml create mode 100644 tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml create mode 100644 tests/components/ezsp_proxy_tap/test.esp32-idf.yaml create mode 100644 tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml create mode 100644 tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 45aa0c49ab..23bac1efae 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -202,6 +202,7 @@ esphome/components/exponential_moving_average/* @clydebarrow esphome/components/exposure_notifications/* @OttoWinter esphome/components/ezo/* @ssieb esphome/components/ezo_pmp/* @carlos-sarmiento +esphome/components/ezsp_proxy_tap/* @kbx81 @puddly esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi diff --git a/esphome/components/ezsp_proxy_tap/__init__.py b/esphome/components/ezsp_proxy_tap/__init__.py new file mode 100644 index 0000000000..207e0cb8b1 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import serial_proxy +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81", "@puddly"] +DEPENDENCIES = ["serial_proxy"] +DOMAIN = "ezsp_proxy_tap" + +CONF_SERIAL_PROXY_ID = "serial_proxy_id" + +ezsp_proxy_tap_ns = cg.esphome_ns.namespace("ezsp_proxy_tap") +EzspProxyTap = ezsp_proxy_tap_ns.class_( + "EzspProxyTap", cg.Component, serial_proxy.SerialProxyTap +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(EzspProxyTap), + cv.Required(CONF_SERIAL_PROXY_ID): cv.use_id(serial_proxy.SerialProxy), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID]) + var = cg.new_Pvariable(config[CONF_ID], sp) + await cg.register_component(var, config) + + cg.add_define("USE_EZSP_PROXY_TAP") + # Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe + cg.add_define("USE_SERIAL_PROXY_TAP") diff --git a/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp b/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp new file mode 100644 index 0000000000..f8010f4618 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp @@ -0,0 +1,126 @@ +#include "ash_acknowledger.h" + +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap { + +// Control byte of an RSTACK, and the only ASH version byte that can follow it +static constexpr uint8_t ASH_RSTACK_CONTROL = 0xC1; +static constexpr uint8_t ASH_PROTOCOL_VERSION = 0x02; +static constexpr size_t ASH_RSTACK_BODY_SIZE = 3; // control, version, reset code +// A DATA frame carries an EZSP frame of at least 3 bytes after its control byte +static constexpr size_t ASH_MIN_DATA_BODY_SIZE = 1 + 3; +static constexpr size_t ASH_CRC_SIZE = 2; +// Smallest legal frame on the wire: a bare control byte plus its CRC +static constexpr size_t ASH_MIN_FRAME_SIZE = 1 + ASH_CRC_SIZE; + +void AshFrameScanner::reset() { + this->index_ = 0; + this->crc_ = ASH_CRC_INIT; + this->escaped_ = false; + this->poisoned_ = false; + this->discarding_ = false; +} + +bool AshFrameScanner::feed(uint8_t byte) { + if (byte == ASH_FLAG_BYTE) { + // Running the CRC over the body and its trailing CRC bytes leaves zero when correct + const bool valid = !this->discarding_ && !this->poisoned_ && !this->escaped_ && + this->index_ >= ASH_MIN_FRAME_SIZE && this->crc_ == 0; + if (valid) { + this->frame_length_ = this->index_ - ASH_CRC_SIZE; + } + this->reset(); + return valid; + } + + // CANCEL also ends a SUBSTITUTE's discard, so the bytes after it start a new frame + if (byte == ASH_CANCEL_BYTE) { + this->reset(); + return false; + } + + if (this->discarding_ || (byte == ASH_WAKE_BYTE && this->index_ == 0)) { + return false; + } + + switch (byte) { + case ASH_SUBSTITUTE_BYTE: + this->discarding_ = true; + return false; + + case ASH_XON_BYTE: + case ASH_XOFF_BYTE: + return false; + + case ASH_ESCAPE_BYTE: + this->escaped_ = true; + return false; + + default: + break; + } + + uint8_t value = byte; + if (this->escaped_) { + this->escaped_ = false; + value = byte ^ ASH_XOR_BYTE; + } + + if (this->index_ >= MAX_ASH_FRAME_SIZE) { + this->poisoned_ = true; + return false; + } + + if (this->index_ < ASH_HEADER_SIZE) { + this->header_[this->index_] = value; + } + this->index_++; + this->crc_ = crc16be(&value, 1, this->crc_); + return false; +} + +void AshAcknowledger::reset() { + this->scanner_.reset(); + this->rx_sequence_ = 0; + this->synced_ = false; +} + +bool AshAcknowledger::feed(uint8_t byte) { return this->scanner_.feed(byte) && this->handle_frame_(); } + +bool AshAcknowledger::handle_frame_() { + const uint8_t *body = this->scanner_.header(); + const uint8_t control = body[0]; + + // An RSTACK restarts the NCP's frame numbering + if (control == ASH_RSTACK_CONTROL) { + if (this->scanner_.length() == ASH_RSTACK_BODY_SIZE && body[1] == ASH_PROTOCOL_VERSION) { + this->rx_sequence_ = 0; + this->synced_ = true; + } + return false; + } + + if ((control & 0x80) != 0) { + return false; // ACK/NAK/RST/ERROR: nothing is owed for these + } + + if (this->scanner_.length() < ASH_MIN_DATA_BODY_SIZE) { + return false; + } + + const uint8_t frame_num = (control >> 4) & ASH_MAX_SEQUENCE; + const bool re_tx = (control & 0x08) != 0; + // Without an RSTACK, the session began before we were watching: follow the NCP's numbering + if (!this->synced_) { + this->rx_sequence_ = frame_num; + this->synced_ = true; + } + if (frame_num == this->rx_sequence_) { + this->rx_sequence_ = (this->rx_sequence_ + 1) & ASH_MAX_SEQUENCE; + return true; + } + return re_tx; +} + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_acknowledger.h b/esphome/components/ezsp_proxy_tap/ash_acknowledger.h new file mode 100644 index 0000000000..1c39e8ec4a --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_acknowledger.h @@ -0,0 +1,54 @@ +#pragma once + +#include "ash_protocol.h" + +#include +#include + +namespace esphome::ezsp_proxy_tap { + +// Reassembles one direction of the byte stream into unstuffed, CRC-checked frames. Only +// the first ASH_HEADER_SIZE bytes of a frame are kept, as the CRC is checked as bytes +// arrive and acknowledging needs nothing past the header. +class AshFrameScanner { + public: + // Returns true when the byte completes a frame with a valid CRC + bool feed(uint8_t byte); + void reset(); + + // The first min(length(), ASH_HEADER_SIZE) bytes of the last completed frame's body. + // Valid only until the next feed() call, which begins overwriting them. + const uint8_t *header() const { return this->header_; } + size_t length() const { return this->frame_length_; } + + private: + uint8_t header_[ASH_HEADER_SIZE]; + size_t index_{0}; // body bytes read so far in the frame being read + size_t frame_length_{0}; // body length of the last completed frame + uint16_t crc_{ASH_CRC_INIT}; + bool escaped_{false}; + bool discarding_{false}; + bool poisoned_{false}; +}; + +// Works out which acknowledgements an NCP is owed while a client has handed them to this +// device. Whether to acknowledge at all is the client's decision, made by putting the port +// in PROTOCOL mode, so this only follows the NCP's frame numbering. +class AshAcknowledger { + public: + // Forgets the NCP's frame numbering. The next RSTACK or DATA frame sets it again. + void reset(); + + // Feed a byte from the NCP. Returns true when it completes a frame that is owed ack_num(). + bool feed(uint8_t byte); + uint8_t ack_num() const { return this->rx_sequence_; } + + protected: + bool handle_frame_(); + + AshFrameScanner scanner_; + uint8_t rx_sequence_{0}; + bool synced_{false}; +}; + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_protocol.cpp b/esphome/components/ezsp_proxy_tap/ash_protocol.cpp new file mode 100644 index 0000000000..bbe9cacc2d --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_protocol.cpp @@ -0,0 +1,19 @@ +#include "ash_protocol.h" + +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap { + +void ash_build_ack_frame(uint8_t *output, uint8_t ack_num) { + // ACK control byte: 100nrPPP, where PPP is the next frame number expected + const uint8_t control = 0x80 | (ack_num & ASH_MAX_SEQUENCE); + const uint16_t crc = crc16be(&control, 1, ASH_CRC_INIT); + + output[0] = ASH_FLAG_BYTE; + output[1] = control; + output[2] = crc >> 8; + output[3] = crc & 0xFF; + output[4] = ASH_FLAG_BYTE; +} + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_protocol.h b/esphome/components/ezsp_proxy_tap/ash_protocol.h new file mode 100644 index 0000000000..4694c74501 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_protocol.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +namespace esphome::ezsp_proxy_tap { + +// ASH Protocol Constants +static constexpr uint8_t ASH_FLAG_BYTE = 0x7E; // Frame delimiter +static constexpr uint8_t ASH_ESCAPE_BYTE = 0x7D; // Escape/substitution byte +static constexpr uint8_t ASH_XOR_BYTE = 0x20; // XOR mask for escaped bytes +static constexpr uint8_t ASH_SUBSTITUTE_BYTE = 0x18; // Substitution for invalid bytes + +static constexpr uint8_t ASH_XON_BYTE = 0x11; // Resume transmission +static constexpr uint8_t ASH_XOFF_BYTE = 0x13; // Pause transmission +static constexpr uint8_t ASH_CANCEL_BYTE = 0x1A; // Discards the partial frame before it +// Sent between frames by sleepy NCPs to signal pending callbacks. No control byte is 0xFF. +static constexpr uint8_t ASH_WAKE_BYTE = 0xFF; + +static constexpr size_t ASH_MAX_DATA_FIELD_SIZE = 220; +static constexpr size_t MAX_ASH_FRAME_SIZE = 1 + ASH_MAX_DATA_FIELD_SIZE + 2; + +// The control byte, then the first Data Field byte (an RSTACK's version) +static constexpr size_t ASH_HEADER_SIZE = 2; + +// Protocol limits +static constexpr uint8_t ASH_MAX_SEQUENCE = 7; // 3-bit sequence number (0-7) + +// The frame CRC is CRC-CCITT: crc16be() with this initial value, transmitted big-endian +static constexpr uint16_t ASH_CRC_INIT = 0xFFFF; + +// FLAG, control byte, two CRC bytes, FLAG. None of the eight ACKs has a byte that needs +// escaping, so the size is fixed. +static constexpr size_t ASH_ACK_FRAME_SIZE = 5; + +// Writes the ACK frame for `ack_num` into `output` +void ash_build_ack_frame(uint8_t *output, uint8_t ack_num); + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp new file mode 100644 index 0000000000..c8557fcc9b --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp @@ -0,0 +1,38 @@ +#include "ezsp_proxy_tap.h" + +#ifdef USE_EZSP_PROXY_TAP + +#include "esphome/core/log.h" + +namespace esphome::ezsp_proxy_tap { + +ESPHOME_LOG_TAG(TAG, "ezsp_proxy_tap"); + +void EzspProxyTap::setup() { this->parent_->set_tap(this); } + +void EzspProxyTap::dump_config() { ESP_LOGCONFIG(TAG, "EZSP Proxy Tap:\n Port: %s", this->parent_->get_name()); } + +void EzspProxyTap::on_device_rx(const uint8_t *data, size_t len) { + for (size_t i = 0; i < len; i++) { + if (this->acknowledger_.feed(data[i])) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, this->acknowledger_.ack_num()); + this->parent_->write_from_tap(frame, sizeof(frame)); + ESP_LOGV(TAG, "Sent ACK %u", this->acknowledger_.ack_num()); + } + } +} + +void EzspProxyTap::on_protocol_enabled() { + // Numbering from an earlier session is stale + this->acknowledger_.reset(); +} + +void EzspProxyTap::on_device_disconnected() { + // The frame numbering ended with the NCP's power; whatever boots next starts over + this->acknowledger_.reset(); +} + +} // namespace esphome::ezsp_proxy_tap + +#endif // USE_EZSP_PROXY_TAP diff --git a/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h new file mode 100644 index 0000000000..2e5a444bec --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_EZSP_PROXY_TAP + +#include "esphome/components/serial_proxy/serial_proxy.h" +#include "esphome/core/component.h" +#include "ash_acknowledger.h" + +namespace esphome::ezsp_proxy_tap { + +// Acknowledges the ASH frames of an EZSP NCP on behalf of a remote client, so the NCP's +// ack timeout is measured against this device rather than against the network round trip +// to the client. The client suppresses its own acknowledgements, making these the only +// ones the NCP sees. +// +// It never carries client traffic: the serial proxy owns the port and the bytes, and this +// component only observes them. The sole exception is the acknowledgement itself, sent +// only while a client has put the port in PROTOCOL mode, which only an EZSP client does. +class EzspProxyTap : public serial_proxy::SerialProxyTap, public Component { + public: + explicit EzspProxyTap(serial_proxy::SerialProxy *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + // SerialProxyTap + void on_device_rx(const uint8_t *data, size_t len) override; + // ACKs depend only on what the NCP sends + void on_client_tx(const uint8_t *data, size_t len) override {} + // Acknowledging is only ever useful on a client's behalf, so with nobody subscribed + // there is nothing to do and the port need not be read. + bool tap_needs_port() const override { return false; } + void on_protocol_enabled() override; + // Nothing is acknowledged outside PROTOCOL mode, and entering it starts afresh + void on_protocol_disabled() override {} + void on_device_disconnected() override; + + protected: + // The port this component observes. Owns the UART and the bytes; every write we make + // goes through it. + serial_proxy::SerialProxy *parent_; + + AshAcknowledger acknowledger_; +}; + +} // namespace esphome::ezsp_proxy_tap + +#endif // USE_EZSP_PROXY_TAP diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index d512362fa0..9623eb21b3 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -277,10 +277,16 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); #ifdef USE_SERIAL_PROXY_TAP + const bool entering_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_PROTOCOL && mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL; const bool leaving_protocol_mode = this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; this->mode_ = mode; + if (entering_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_enabled(); + } + // Only for an explicit client request, not for reset_mode_() at the end of a session: // an ordinary disconnect says nothing about the device, whereas a client deliberately // asking for raw bytes usually precedes changing what the device is. @@ -426,6 +432,14 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: void SerialProxy::on_usb_connection_changed_(bool connected) { ESP_LOGD(TAG, "USB device %s serial proxy [%" PRIu32 "]", connected ? LOG_STR_LITERAL("attached to") : LOG_STR_LITERAL("removed from"), this->instance_index_); +#ifdef USE_SERIAL_PROXY_TAP + // Before telling clients, so a tap never acknowledges a frame from the old device after + // a client has been told it is gone. The subscriber and the mode stay: both belong to + // the client's session, and only the client knows whether that session is over. + if (!connected && this->tap_ != nullptr) { + this->tap_->on_device_disconnected(); + } +#endif #ifdef USE_API if (api::global_api_server == nullptr) { return; diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 1a406bc089..2b8f933f88 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -89,11 +89,20 @@ class SerialProxyTap { /// subscriber holds the port; with one attached, the port mode alone decides. virtual bool tap_needs_port() const = 0; + /// A client turned protocol handling on for this port. The tap has seen none of the + /// traffic since it was last on, so anything it remembers from then is stale. + virtual void on_protocol_enabled() = 0; + /// A client explicitly turned protocol handling off for this port. Distinct from the /// automatic reset when a session ends: this one means a client intends to do something /// else with the device -- reflash it, most likely -- so anything the tap believes about /// it should be treated as suspect. virtual void on_protocol_disabled() = 0; + + /// The device behind the port went away -- unplugged, or its power was cut. Whatever + /// session the tap had observed ended with it; the device that appears next starts from + /// scratch and may not even be the same one. Only a USB UART can report this. + virtual void on_device_disconnected() = 0; }; #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 87dc703b37..a1b32b39f5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -73,6 +73,7 @@ #define USE_ESP32_HOSTED_HTTP_UPDATE #define USE_ESP_NOW_HOSTED #define USE_EVENT +#define USE_EZSP_PROXY_TAP #define USE_FAN #define USE_FEEDBACK_COVER_OVERSHOOT #define USE_GPIO_BINARY_SENSOR_INTERRUPT diff --git a/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp b/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp new file mode 100644 index 0000000000..5ee22ae9d4 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp @@ -0,0 +1,185 @@ +#include + +#include +#include +#include + +#include "esphome/components/ezsp_proxy_tap/ash_acknowledger.h" +#include "esphome/components/ezsp_proxy_tap/ash_protocol.h" +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap::testing { + +namespace { + +// Whole frames as they appear on the wire, each ending in its FLAG. RSTACK and ACK(1) +// are the examples in Silicon Labs UG101. +const std::vector RSTACK = {0xC1, 0x02, 0x02, 0x9B, 0x7B, 0x7E}; +const std::vector DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA8, 0x50, 0x9B, 0x98, 0x7E}; +const std::vector DATA_FRAME_1 = {0x10, 0x43, 0x21, 0xA8, 0x50, 0x9F, 0xC2, 0x7E}; +const std::vector DATA_FRAME_0_RETRANSMITTED = {0x08, 0x43, 0x21, 0xA8, 0x50, 0x99, 0xB5, 0x7E}; +const std::vector DATA_FRAME_5 = {0x50, 0x43, 0x21, 0xA8, 0x50, 0x8E, 0xAA, 0x7E}; +const std::vector DATA_FRAME_5_RETRANSMITTED = {0x58, 0x43, 0x21, 0xA8, 0x50, 0x8C, 0x87, 0x7E}; +const std::vector DATA_FRAME_6 = {0x60, 0x43, 0x21, 0xA8, 0x50, 0x82, 0x44, 0x7E}; +// Data frame 0 with a 2-byte and a 3-byte data field, below and at the minimum +const std::vector SHORT_DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA0, 0x40, 0x7E}; +const std::vector MIN_DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA8, 0xC1, 0x08, 0x7E}; +// Body {0x00, 0x7E, 0x7D, 0x11, 0x13, 0x18, 0x1A}: every reserved byte, each escaped +const std::vector ESCAPED_FRAME = {0x00, 0x7D, 0x5E, 0x7D, 0x5D, 0x7D, 0x31, 0x7D, + 0x33, 0x7D, 0x38, 0x7D, 0x3A, 0x46, 0x8C, 0x7E}; + +const std::vector RESERVED_BYTES = {ASH_FLAG_BYTE, ASH_ESCAPE_BYTE, ASH_XON_BYTE, + ASH_XOFF_BYTE, ASH_SUBSTITUTE_BYTE, ASH_CANCEL_BYTE}; + +// Data frame 0 with `data_size` bytes of 0x42 and a valid CRC, none of which needs escaping +std::vector unstuffed_frame(size_t data_size) { + std::vector frame(1 + data_size, 0x42); + frame[0] = 0x00; + const uint16_t crc = crc16be(frame.data(), frame.size(), ASH_CRC_INIT); + frame.push_back(crc >> 8); + frame.push_back(crc & 0xFF); + frame.push_back(ASH_FLAG_BYTE); + return frame; +} + +std::vector concat(std::vector a, const std::vector &b) { + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +// Returns whether the last byte completed a valid frame +bool feed(AshFrameScanner &scanner, const std::vector &bytes) { + bool complete = false; + for (uint8_t byte : bytes) + complete = scanner.feed(byte); + return complete; +} + +// Feeds whole frames and returns the ACK owed after the last, or -1 if none is +int feed(AshAcknowledger &acknowledger, const std::vector &bytes) { + bool owed = false; + for (uint8_t byte : bytes) + owed = acknowledger.feed(byte); + return owed ? acknowledger.ack_num() : -1; +} + +} // namespace + +TEST(AshProtocol, AckFrameMatchesReference) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, 1); + EXPECT_EQ(std::vector(frame, frame + sizeof(frame)), (std::vector{0x7E, 0x81, 0x60, 0x59, 0x7E})); +} + +TEST(AshProtocol, AckFramesNeedNoEscaping) { + for (uint8_t ack_num = 0; ack_num <= ASH_MAX_SEQUENCE; ack_num++) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, ack_num); + EXPECT_EQ(frame[0], ASH_FLAG_BYTE); + EXPECT_EQ(frame[4], ASH_FLAG_BYTE); + for (size_t i = 1; i < 4; i++) { + EXPECT_EQ(std::count(RESERVED_BYTES.begin(), RESERVED_BYTES.end(), frame[i]), 0) + << "ack_num " << int(ack_num) << " byte " << i; + } + } +} + +TEST(AshFrameScanner, UnescapesReservedBytes) { + AshFrameScanner scanner; + // The CRC covers the unescaped body, so a valid frame proves every byte was restored + ASSERT_TRUE(feed(scanner, ESCAPED_FRAME)); + EXPECT_EQ(scanner.length(), 7u); + EXPECT_EQ(std::vector(scanner.header(), scanner.header() + ASH_HEADER_SIZE), + (std::vector{0x00, 0x7E})); +} + +TEST(AshFrameScanner, RejectsBadCrc) { + AshFrameScanner scanner; + std::vector corrupt = DATA_FRAME_0; + corrupt[corrupt.size() - 2] ^= 0x01; + EXPECT_FALSE(feed(scanner, corrupt)); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshFrameScanner, CancelDiscardsPartialFrame) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({0x10, 0x43, ASH_CANCEL_BYTE}, DATA_FRAME_0))); + EXPECT_EQ(scanner.length(), 5u); +} + +TEST(AshFrameScanner, SubstituteDiscardsUntilFlag) { + AshFrameScanner scanner; + std::vector substituted = DATA_FRAME_0; + substituted.insert(substituted.begin() + 2, ASH_SUBSTITUTE_BYTE); + EXPECT_FALSE(feed(scanner, substituted)); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshFrameScanner, CancelEndsSubstitute) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({0x10, ASH_SUBSTITUTE_BYTE, 0x43, ASH_CANCEL_BYTE}, DATA_FRAME_0))); +} + +TEST(AshFrameScanner, IgnoresWakeBetweenFrames) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({ASH_WAKE_BYTE, ASH_FLAG_BYTE, ASH_WAKE_BYTE, ASH_WAKE_BYTE}, DATA_FRAME_0))); + EXPECT_EQ(scanner.length(), 5u); +} + +TEST(AshFrameScanner, IgnoresFlowControlInsideFrame) { + AshFrameScanner scanner; + std::vector interrupted = DATA_FRAME_0; + interrupted.insert(interrupted.begin() + 3, ASH_XOFF_BYTE); + interrupted.insert(interrupted.begin() + 1, ASH_XON_BYTE); + ASSERT_TRUE(feed(scanner, interrupted)); + EXPECT_EQ(scanner.length(), 5u); + EXPECT_EQ(std::vector(scanner.header(), scanner.header() + ASH_HEADER_SIZE), + (std::vector{0x00, 0x43})); +} + +TEST(AshFrameScanner, RejectsOversizedFrame) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, unstuffed_frame(ASH_MAX_DATA_FIELD_SIZE))); + EXPECT_EQ(scanner.length(), 1 + ASH_MAX_DATA_FIELD_SIZE); + EXPECT_FALSE(feed(scanner, unstuffed_frame(ASH_MAX_DATA_FIELD_SIZE + 1))); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshAcknowledger, AcknowledgesFramesInSequence) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + // A repeat not marked as a retransmission is out of sequence + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), -1); + // A retransmission means the NCP missed our ACK, so it gets the same one again + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0_RETRANSMITTED), 1); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_1), 2); +} + +TEST(AshAcknowledger, RstackRestartsNumbering) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + EXPECT_EQ(feed(acknowledger, RSTACK), -1); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); +} + +TEST(AshAcknowledger, IgnoresShortDataFrames) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, SHORT_DATA_FRAME_0), -1); + EXPECT_EQ(feed(acknowledger, MIN_DATA_FRAME_0), 1); +} + +TEST(AshAcknowledger, SyncsToFirstFrameMidSession) { + AshAcknowledger acknowledger; + // The NCP is retransmitting a frame nobody acknowledged before the tap was watching + EXPECT_EQ(feed(acknowledger, DATA_FRAME_5_RETRANSMITTED), 6); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_6), 7); +} + +TEST(AshAcknowledger, ResetForgetsNumbering) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + acknowledger.reset(); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_5), 6); +} + +} // namespace esphome::ezsp_proxy_tap::testing diff --git a/tests/components/ezsp_proxy_tap/common.yaml b/tests/components/ezsp_proxy_tap/common.yaml new file mode 100644 index 0000000000..64391dc080 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/common.yaml @@ -0,0 +1,15 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +api: + +# The port owns the UART and carries every byte; ezsp_proxy_tap only taps it +serial_proxy: + - id: ezsp_serial + name: Zigbee + port_type: TTL + +ezsp_proxy_tap: + serial_proxy_id: ezsp_serial diff --git a/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml b/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml new file mode 100644 index 0000000000..4c64260e86 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml @@ -0,0 +1,27 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +api: + +usb_host: + +usb_uart: + - type: CDC_ACM + vid: 0x303A + pid: 0x831A + channels: + - id: ezsp_usb_channel + baud_rate: 460800 + +# The tapped port may be a USB CDC ACM channel just as well as a hardware UART: +# ezsp_proxy_tap never touches the UART itself, so it does not care which it is. +serial_proxy: + - id: ezsp_usb_serial + uart_id: ezsp_usb_channel + name: Zigbee + port_type: USB_SERIAL + +ezsp_proxy_tap: + serial_proxy_id: ezsp_usb_serial diff --git a/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml b/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml new file mode 100644 index 0000000000..afdbf0e676 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + ezsp_proxy_tap: !include common.yaml diff --git a/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml b/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml new file mode 100644 index 0000000000..fd21fecf7f --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + ezsp_proxy_tap: !include common.yaml diff --git a/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml b/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml new file mode 100644 index 0000000000..a979e3fda1 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + ezsp_proxy_tap: !include common.yaml From 88668f448e0638149e000963867867c5b0d38b88 Mon Sep 17 00:00:00 2001 From: B48D81EFCC <111175947+B48D81EFCC@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:53:16 +0900 Subject: [PATCH 222/268] [max17048] Add MAX17048 Li+ Cell Fuel Gauge IC (#13585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Martín Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/max17048/__init__.py | 2 + esphome/components/max17048/max17048.cpp | 77 +++++++++++++++++++ esphome/components/max17048/max17048.h | 20 +++++ esphome/components/max17048/sensor.py | 66 ++++++++++++++++ tests/components/max17048/common.yaml | 8 ++ tests/components/max17048/test.esp32-idf.yaml | 3 + .../components/max17048/test.esp8266-ard.yaml | 3 + .../components/max17048/test.rp2040-ard.yaml | 3 + 9 files changed, 183 insertions(+) create mode 100644 esphome/components/max17048/__init__.py create mode 100644 esphome/components/max17048/max17048.cpp create mode 100644 esphome/components/max17048/max17048.h create mode 100644 esphome/components/max17048/sensor.py create mode 100644 tests/components/max17048/common.yaml create mode 100644 tests/components/max17048/test.esp32-idf.yaml create mode 100644 tests/components/max17048/test.esp8266-ard.yaml create mode 100644 tests/components/max17048/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 23bac1efae..050f4d19d9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -343,6 +343,7 @@ esphome/components/m5stack_8angle/* @rnauber esphome/components/mapping/* @clydebarrow esphome/components/matrix_keypad/* @ssieb esphome/components/max17043/* @blacknell +esphome/components/max17048/* @B48D81EFCC esphome/components/max31865/* @DAVe3283 esphome/components/max31888/* @JiriPrchal esphome/components/max44009/* @berfenger diff --git a/esphome/components/max17048/__init__.py b/esphome/components/max17048/__init__.py new file mode 100644 index 0000000000..ecd6a79dd5 --- /dev/null +++ b/esphome/components/max17048/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@B48D81EFCC"] +DOMAIN = "max17048" diff --git a/esphome/components/max17048/max17048.cpp b/esphome/components/max17048/max17048.cpp new file mode 100644 index 0000000000..67f9dbaac3 --- /dev/null +++ b/esphome/components/max17048/max17048.cpp @@ -0,0 +1,77 @@ +#include "max17048.h" +#include "esphome/core/log.h" + +namespace esphome::max17048 { + +// MAX17048/MAX17049 1 and 2 cell fuel gauges with ModelGauge +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/MAX17048-MAX17049.pdf + +ESPHOME_LOG_TAG(TAG, "max17048"); + +static constexpr uint8_t REG_VCELL = 0x02; // cell voltage +static constexpr uint8_t REG_SOC = 0x04; // state of charge +static constexpr uint8_t REG_VERSION = 0x08; // 0x001x +static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed + +static constexpr uint16_t VERSION_MASK = 0xFFF0; +static constexpr uint16_t VERSION_FAMILY = 0x0010; + +static constexpr float VCELL_VOLTS_PER_LSB = 78.125e-6f; +static constexpr float SOC_PERCENT_PER_LSB = 1.0f / 256.0f; +static constexpr float CRATE_PERCENT_PER_HOUR_PER_LSB = 0.208f; + +void MAX17048Component::setup() { + uint16_t version; + if (!this->read_byte_16(REG_VERSION, &version)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if ((version & VERSION_MASK) != VERSION_FAMILY) { + ESP_LOGW(TAG, "Unexpected version 0x%04X", version); + } +} + +void MAX17048Component::update() { + bool ok = true; + uint16_t raw; + if (this->battery_voltage_sensor_ != nullptr) { + if (this->read_byte_16(REG_VCELL, &raw)) { + this->battery_voltage_sensor_->publish_state(raw * VCELL_VOLTS_PER_LSB); + } else { + ok = false; + } + } + if (this->battery_level_sensor_ != nullptr) { + if (this->read_byte_16(REG_SOC, &raw)) { + this->battery_level_sensor_->publish_state(raw * SOC_PERCENT_PER_LSB); + } else { + ok = false; + } + } + if (this->rate_sensor_ != nullptr) { + if (this->read_byte_16(REG_CRATE, &raw)) { + this->rate_sensor_->publish_state(static_cast(raw) * CRATE_PERCENT_PER_HOUR_PER_LSB); + } else { + ok = false; + } + } + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR("Register read failed")); + } +} + +void MAX17048Component::dump_config() { + ESP_LOGCONFIG(TAG, "MAX17048:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Rate", this->rate_sensor_); +} + +} // namespace esphome::max17048 diff --git a/esphome/components/max17048/max17048.h b/esphome/components/max17048/max17048.h new file mode 100644 index 0000000000..906d4a7c3f --- /dev/null +++ b/esphome/components/max17048/max17048.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::max17048 { + +class MAX17048Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(battery_voltage) + SUB_SENSOR(battery_level) + SUB_SENSOR(rate) + + public: + void setup() override; + void dump_config() override; + void update() override; +}; + +} // namespace esphome::max17048 diff --git a/esphome/components/max17048/sensor.py b/esphome/components/max17048/sensor.py new file mode 100644 index 0000000000..30cd0ec59f --- /dev/null +++ b/esphome/components/max17048/sensor.py @@ -0,0 +1,66 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_BATTERY_VOLTAGE, + CONF_ID, + CONF_RATE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_PERCENT, + UNIT_VOLT, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +UNIT_PERCENT_PER_HOUR = "%/h" + +max17048_ns = cg.esphome_ns.namespace("max17048") +MAX17048Component = max17048_ns.class_( + "MAX17048Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MAX17048Component), + cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT_PER_HOUR, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x36)) +) + + +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) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_RATE, var.set_rate_sensor) diff --git a/tests/components/max17048/common.yaml b/tests/components/max17048/common.yaml new file mode 100644 index 0000000000..de0247ed4e --- /dev/null +++ b/tests/components/max17048/common.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: max17048 + battery_voltage: + name: Battery Voltage + battery_level: + name: Battery Level + rate: + name: Battery Rate diff --git a/tests/components/max17048/test.esp32-idf.yaml b/tests/components/max17048/test.esp32-idf.yaml new file mode 100644 index 0000000000..e9930c953d --- /dev/null +++ b/tests/components/max17048/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + max17048: !include common.yaml diff --git a/tests/components/max17048/test.esp8266-ard.yaml b/tests/components/max17048/test.esp8266-ard.yaml new file mode 100644 index 0000000000..95f0f76f14 --- /dev/null +++ b/tests/components/max17048/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + max17048: !include common.yaml diff --git a/tests/components/max17048/test.rp2040-ard.yaml b/tests/components/max17048/test.rp2040-ard.yaml new file mode 100644 index 0000000000..7f24480cdc --- /dev/null +++ b/tests/components/max17048/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + max17048: !include common.yaml From d08d324a8342897c9fce17e1d45cfc3a67375e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Tue, 13 Oct 2026 00:54:35 +0200 Subject: [PATCH 223/268] [bq25186] Add bq25186 1-Cell, 1A Linear Battery Charger component (part 1) (#15700) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bq25186/__init__.py | 314 ++++++++++++++++++ esphome/components/bq25186/bq25186.cpp | 79 +++++ esphome/components/bq25186/bq25186.h | 44 +++ tests/component_tests/bq25186/__init__.py | 0 .../bq25186/test_pack_registers.py | 87 +++++ tests/components/bq25186/common.yaml | 25 ++ tests/components/bq25186/test.esp32-idf.yaml | 3 + .../components/bq25186/test.esp8266-ard.yaml | 3 + 9 files changed, 556 insertions(+) create mode 100644 esphome/components/bq25186/__init__.py create mode 100644 esphome/components/bq25186/bq25186.cpp create mode 100644 esphome/components/bq25186/bq25186.h create mode 100644 tests/component_tests/bq25186/__init__.py create mode 100644 tests/component_tests/bq25186/test_pack_registers.py create mode 100644 tests/components/bq25186/common.yaml create mode 100644 tests/components/bq25186/test.esp32-idf.yaml create mode 100644 tests/components/bq25186/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 050f4d19d9..d5866286fd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -101,6 +101,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bq25186/* @Rapsssito esphome/components/bq27220/* @rggammon esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py new file mode 100644 index 0000000000..e57860786f --- /dev/null +++ b/esphome/components/bq25186/__init__.py @@ -0,0 +1,314 @@ +from collections.abc import Callable +from typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE, ID, Lambda +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@Rapsssito"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bq25186" +MULTI_CONF = True + +CONF_CONFIGURE_ON_BOOT = "configure_on_boot" +CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage" +CONF_FAST_CHARGE_CURRENT = "fast_charge_current" +CONF_CHARGE_ENABLED = "charge_enabled" +CONF_FLASH_CHARGING_MODE = "flash_charging_mode" +CONF_PRECHARGE_IS_ITERM = "precharge_is_iterm" +CONF_TERMINATION_PERCENT = "termination_percent" +CONF_VINDPM_MODE = "vindpm_mode" +CONF_THERMAL_REGULATION = "thermal_regulation" +CONF_BATTERY_OCP_LIMIT = "battery_ocp_limit" +CONF_BATTERY_UVLO = "battery_uvlo" +CONF_MASK_CHARGE_STATUS_INTERRUPT = "mask_charge_status_interrupt" +CONF_MASK_ILIM_INTERRUPT = "mask_ilim_interrupt" +CONF_MASK_VINDPM_INTERRUPT = "mask_vindpm_interrupt" +CONF_TS_AUTO_FUNCTION = "ts_auto_function" +CONF_VLOWV = "vlowv" +CONF_RECHARGE_THRESHOLD = "recharge_threshold" +CONF_DOUBLE_TIMER_DURING_DPM = "double_timer_during_dpm" +CONF_SAFETY_TIMER = "safety_timer" +CONF_LONG_PRESS_DURATION = "long_press_duration" +CONF_HW_RESET_REQUIRES_VIN = "hw_reset_requires_vin" +CONF_AUTOWAKE = "autowake" +CONF_INPUT_CURRENT_LIMIT = "input_current_limit" +CONF_PUSH_BUTTON_LONG_PRESS_ACTION = "push_button_long_press_action" +CONF_WAKE1_TIMER = "wake1_timer" +CONF_WAKE2_TIMER = "wake2_timer" +CONF_ENABLE_PUSH_BUTTON = "enable_push_button" +CONF_SYSTEM_REGULATION = "system_regulation" +CONF_SYS_MODE = "sys_mode" +CONF_DISABLE_VDPPM = "disable_vdppm" +CONF_TS_HOT = "ts_hot" +CONF_TS_COLD = "ts_cold" +CONF_TS_WARM_DISABLE = "ts_warm_disable" +CONF_TS_COOL_DISABLE = "ts_cool_disable" +CONF_TS_ICHG = "ts_ichg" +CONF_TS_VRCG = "ts_vrcg" +CONF_MASK_TS_INTERRUPT = "mask_ts_interrupt" +CONF_MASK_TREG_INTERRUPT = "mask_treg_interrupt" +CONF_MASK_BAT_INTERRUPT = "mask_bat_interrupt" +CONF_MASK_PG_INTERRUPT = "mask_pg_interrupt" + +bq25186_ns = cg.esphome_ns.namespace("bq25186") +BQ25186Component = bq25186_ns.class_("BQ25186Component", cg.Component, i2c.I2CDevice) + +REG_VBAT_CTRL = 0x03 +REG_ICHG_CTRL = 0x04 +REG_CHARGECTRL0 = 0x05 +REG_CHARGECTRL1 = 0x06 +REG_IC_CTRL = 0x07 +REG_TMR_ILIM = 0x08 +REG_SHIP_RST = 0x09 +REG_SYS_REG = 0x0A +REG_TS_CONTROL = 0x0B +REG_MASK_ID = 0x0C +REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1 +WATCHDOG_DISABLED = 0x03 + +TERMINATION_PERCENT_OPTIONS = {"disabled": 0, "5%": 1, "10%": 2, "20%": 3} +VINDPM_MODE_OPTIONS = {"battery_tracking": 0, "4.5v": 1, "4.7v": 2, "disabled": 3} +THERM_REGULATION_OPTIONS = {"100c": 0, "80c": 1, "60c": 2, "disabled": 3} +BATTERY_OCP_LIMIT_OPTIONS = {"500ma": 0, "1000ma": 1, "1500ma": 2, "3000ma": 3} +BATTERY_UVLO_OPTIONS = { + "3.0v": 0, + "2.8v": 3, + "2.6v": 4, + "2.4v": 5, + "2.2v": 6, + "2.0v": 7, +} +VLOWV_OPTIONS = {"3.0v": 0, "2.8v": 1} +RECHARGE_THRESHOLD_OPTIONS = {"100mv": 0, "200mv": 1} +SAFETY_TIMER_OPTIONS = {"3h": 0, "6h": 1, "12h": 2, "disabled": 3} +LONG_PRESS_DURATION_OPTIONS = {"5s": 0, "10s": 1, "15s": 2, "20s": 3} +AUTOWAKE_OPTIONS = {"0.5s": 0, "1s": 1, "2s": 2, "4s": 3} +INPUT_CURRENT_LIMIT_OPTIONS = { + "50ma": 0, + "100ma": 1, + "200ma": 2, + "300ma": 3, + "400ma": 4, + "500ma": 5, + "665ma": 6, + "1050ma": 7, +} +PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS = { + "none": 0, + "hardware_reset": 1, + "ship_mode": 2, + "shutdown_mode": 3, +} +WAKE1_TIMER_OPTIONS = {"300ms": 0, "1s": 1} +WAKE2_TIMER_OPTIONS = {"2s": 0, "3s": 1} +SYSTEM_REGULATION_OPTIONS = { + "battery_tracking": 0, + "4.4v": 1, + "4.5v": 2, + "4.6v": 3, + "4.7v": 4, + "4.8v": 5, + "4.9v": 6, + "pass_through": 7, +} +SYS_MODE_OPTIONS = { + "normal": 0, + "battery_only": 1, + "floating_off": 2, + "pulldown_off": 3, +} +TS_HOT_OPTIONS = {"60c": 0, "65c": 1, "50c": 2, "45c": 3} +TS_COLD_OPTIONS = {"0c": 0, "3c": 1, "5c": 2, "-3c": 3} +TS_ICHG_OPTIONS = {"0.5x": 0, "0.2x": 1} +TS_VRCG_OPTIONS = {"-100mv": 0, "-200mv": 1} + + +def _on_voltage_step(volts: float) -> float: + millivolts = volts * 1000 + if abs(millivolts - round(millivolts / 10) * 10) > 1e-6: + raise cv.Invalid("battery_regulation_voltage must be a multiple of 10 mV") + return volts + + +def _on_current_step(amps: float) -> float: + milliamps = amps * 1000 + step = 1 if milliamps < 36 else 10 + if abs(milliamps - round(milliamps / step) * step) > 1e-6 or 35 < milliamps < 40: + raise cv.Invalid( + "fast_charge_current must be a whole number of mA up to 35mA, then a multiple of 10 mA" + ) + return amps + + +# (config key, register, bit shift, validator or enum options, default). +# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; they are +# driven at runtime, not from configuration. +FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( + ( + CONF_BATTERY_REGULATION_VOLTAGE, + REG_VBAT_CTRL, + 0, + cv.All(cv.voltage, cv.Range(min=3.5, max=4.65), _on_voltage_step), + "4.2V", + ), + (CONF_CHARGE_ENABLED, REG_ICHG_CTRL, 7, cv.boolean, True), + ( + CONF_FAST_CHARGE_CURRENT, + REG_ICHG_CTRL, + 0, + cv.All(cv.current, cv.Range(min=0.005, max=1.0), _on_current_step), + "10mA", + ), + (CONF_FLASH_CHARGING_MODE, REG_CHARGECTRL0, 7, cv.boolean, False), + (CONF_PRECHARGE_IS_ITERM, REG_CHARGECTRL0, 6, cv.boolean, False), + (CONF_TERMINATION_PERCENT, REG_CHARGECTRL0, 4, TERMINATION_PERCENT_OPTIONS, "10%"), + (CONF_VINDPM_MODE, REG_CHARGECTRL0, 2, VINDPM_MODE_OPTIONS, "4.5v"), + (CONF_THERMAL_REGULATION, REG_CHARGECTRL0, 0, THERM_REGULATION_OPTIONS, "100c"), + (CONF_BATTERY_OCP_LIMIT, REG_CHARGECTRL1, 6, BATTERY_OCP_LIMIT_OPTIONS, "3000ma"), + (CONF_BATTERY_UVLO, REG_CHARGECTRL1, 3, BATTERY_UVLO_OPTIONS, "3.0v"), + (CONF_MASK_CHARGE_STATUS_INTERRUPT, REG_CHARGECTRL1, 2, cv.boolean, True), + (CONF_MASK_ILIM_INTERRUPT, REG_CHARGECTRL1, 1, cv.boolean, True), + (CONF_MASK_VINDPM_INTERRUPT, REG_CHARGECTRL1, 0, cv.boolean, False), + (CONF_TS_AUTO_FUNCTION, REG_IC_CTRL, 7, cv.boolean, True), + (CONF_VLOWV, REG_IC_CTRL, 6, VLOWV_OPTIONS, "3.0v"), + (CONF_RECHARGE_THRESHOLD, REG_IC_CTRL, 5, RECHARGE_THRESHOLD_OPTIONS, "100mv"), + (CONF_DOUBLE_TIMER_DURING_DPM, REG_IC_CTRL, 4, cv.boolean, False), + (CONF_SAFETY_TIMER, REG_IC_CTRL, 2, SAFETY_TIMER_OPTIONS, "6h"), + (CONF_LONG_PRESS_DURATION, REG_TMR_ILIM, 6, LONG_PRESS_DURATION_OPTIONS, "10s"), + (CONF_HW_RESET_REQUIRES_VIN, REG_TMR_ILIM, 5, cv.boolean, False), + (CONF_AUTOWAKE, REG_TMR_ILIM, 3, AUTOWAKE_OPTIONS, "1s"), + (CONF_INPUT_CURRENT_LIMIT, REG_TMR_ILIM, 0, INPUT_CURRENT_LIMIT_OPTIONS, "500ma"), + ( + CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + REG_SHIP_RST, + 3, + PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS, + "ship_mode", + ), + (CONF_WAKE1_TIMER, REG_SHIP_RST, 2, WAKE1_TIMER_OPTIONS, "300ms"), + (CONF_WAKE2_TIMER, REG_SHIP_RST, 1, WAKE2_TIMER_OPTIONS, "2s"), + (CONF_ENABLE_PUSH_BUTTON, REG_SHIP_RST, 0, cv.boolean, True), + (CONF_SYSTEM_REGULATION, REG_SYS_REG, 5, SYSTEM_REGULATION_OPTIONS, "4.5v"), + (CONF_SYS_MODE, REG_SYS_REG, 2, SYS_MODE_OPTIONS, "normal"), + (CONF_DISABLE_VDPPM, REG_SYS_REG, 0, cv.boolean, False), + (CONF_TS_HOT, REG_TS_CONTROL, 6, TS_HOT_OPTIONS, "60c"), + (CONF_TS_COLD, REG_TS_CONTROL, 4, TS_COLD_OPTIONS, "0c"), + (CONF_TS_WARM_DISABLE, REG_TS_CONTROL, 3, cv.boolean, False), + (CONF_TS_COOL_DISABLE, REG_TS_CONTROL, 2, cv.boolean, False), + (CONF_TS_ICHG, REG_TS_CONTROL, 1, TS_ICHG_OPTIONS, "0.5x"), + (CONF_TS_VRCG, REG_TS_CONTROL, 0, TS_VRCG_OPTIONS, "-100mv"), + (CONF_MASK_TS_INTERRUPT, REG_MASK_ID, 7, cv.boolean, False), + (CONF_MASK_TREG_INTERRUPT, REG_MASK_ID, 6, cv.boolean, True), + (CONF_MASK_BAT_INTERRUPT, REG_MASK_ID, 5, cv.boolean, False), + (CONF_MASK_PG_INTERRUPT, REG_MASK_ID, 4, cv.boolean, False), +) + + +def _battery_regulation_code(volts: float) -> int: + """VBAT_CTRL[6:0]: 3.5 V plus 10 mV per step.""" + return (round(volts * 1000) - 3500) // 10 + + +def _fast_charge_code(amps: float) -> int: + """ICHG_CTRL[6:0]: 1 mA steps from 5 mA to 35 mA, then 10 mA steps from 40 mA.""" + milliamps = round(amps * 1000) + if milliamps <= 35: + return milliamps - 5 + return milliamps // 10 + 27 + + +_ENCODERS: dict[str, Callable[[Any], int]] = { + CONF_BATTERY_REGULATION_VOLTAGE: _battery_regulation_code, + CONF_FAST_CHARGE_CURRENT: _fast_charge_code, + CONF_CHARGE_ENABLED: lambda enabled: not enabled, +} + + +def pack_registers(config: ConfigType) -> list[int]: + """Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes.""" + regs = [0] * REGISTER_COUNT + # Watchdog disabled (IC_CTRL[1:0] = 3): nothing talks to the chip after boot yet, and a running + # watchdog restores the power-on defaults after 160 s. Part 2 adds the option with the polling. + regs[REG_IC_CTRL - REG_VBAT_CTRL] = WATCHDOG_DISABLED + for key, register, shift, validator, _ in FIELDS: + value = config[key] + if isinstance(validator, dict): + value = validator[value] + elif (encode := _ENCODERS.get(key)) is not None: + value = encode(value) + regs[register - REG_VBAT_CTRL] |= int(value) << shift + return regs + + +def _field_schema(with_defaults: bool) -> cv.Schema: + schema = {} + for key, _, _, validator, default in FIELDS: + if isinstance(validator, dict): + validator = cv.enum(validator, lower=True) + optional = ( + cv.Optional(key, default=default) if with_defaults else cv.Optional(key) + ) + schema[optional] = validator + return cv.Schema(schema) + + +BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ25186Component), + cv.Optional(CONF_CONFIGURE_ON_BOOT, default=True): cv.boolean, + } + ) + .extend(BQ25186_CONFIG_SCHEMA) + .extend(i2c.i2c_device_schema(0x6A)) +) + +CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(BQ25186Component), + } + ).extend(_field_schema(with_defaults=False)) +) + + +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) + + if config[CONF_CONFIGURE_ON_BOOT]: + cg.add(var.set_setup_registers(cg.ArrayInitializer(*pack_registers(config)))) + + +@automation.register_action( + "bq25186.apply_configuration", + automation.LambdaAction, + CONFIGURE_ACTION_SCHEMA, + synchronous=True, +) +async def bq25186_configure_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> cg.MockObj: + parent = await cg.get_variable(config[CONF_ID]) + + # Options the action leaves out fall back to the component's configuration + path = CORE.config.get_path_for_id(config[CONF_ID])[:-1] + parent_config = CORE.config.get_config_for_path(path) + regs = pack_registers({**parent_config, **config}) + + text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs)))) + lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) + return automation.new_lambda_pvariable( + action_id, lambda_, automation.StatelessLambdaAction, template_arg + ) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp new file mode 100644 index 0000000000..e660ec63d1 --- /dev/null +++ b/esphome/components/bq25186/bq25186.cpp @@ -0,0 +1,79 @@ +#include "bq25186.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::bq25186 { + +ESPHOME_LOG_TAG(TAG, "bq25186"); + +// Bits the configuration owns in each register; the rest are runtime state (VBAT_CTRL pg_mode, +// SYS_REG GPO level), reset triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read +static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = { + 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, +}; + +bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) { + if (!this->write_byte(reg, value)) { + ESP_LOGW(TAG, "Failed to write register 0x%02X", reg); + return false; + } + return true; +} + +bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value) { + uint8_t reg_value; + if (!this->read_byte(reg, ®_value)) { + ESP_LOGW(TAG, "Failed to read register 0x%02X", reg); + return false; + } + reg_value = (reg_value & ~mask) | (value & mask); + return this->write_register_(reg, reg_value); +} + +bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { + // Setup rejects an unknown chip; the action must not program it before that check or after it failed + if (!this->is_ready() || this->is_failed()) { + ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded"); + return false; + } + for (size_t i = 0; i < regs.size(); i++) { + const uint8_t reg = BQ25186_REG_VBAT_CTRL + i; + const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]); + const bool ok = mask == 0xFF ? this->write_register_(reg, regs[i]) : this->update_register_(reg, mask, regs[i]); + if (!ok) { + this->status_set_warning(LOG_STR("Configuration partly applied")); + return false; + } + } + this->status_clear_warning(); + return true; +} + +void BQ25186Component::setup() { + uint8_t mask_id; + if (!this->read_byte(BQ25186_REG_MASK_ID, &mask_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Another charger at this address could lay its registers out differently, so do not write into it + const uint8_t device_id = mask_id & 0x0F; + if (device_id != 0x01) { + ESP_LOGE(TAG, "Unexpected device id 0x%X (expected 0x1)", device_id); + this->mark_failed(LOG_STR("Not a BQ25186")); + return; + } + + // A failed write only sets the warning status: the chip has identified itself, so a later + // apply_configuration may retry + if (this->configure_on_boot_) + this->apply_config(this->setup_regs_); +} + +void BQ25186Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ25186:"); + LOG_I2C_DEVICE(this); +} + +} // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h new file mode 100644 index 0000000000..a692087147 --- /dev/null +++ b/esphome/components/bq25186/bq25186.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" + +#include + +namespace esphome::bq25186 { + +static constexpr uint8_t BQ25186_REG_VBAT_CTRL = 0x03; +static constexpr uint8_t BQ25186_REG_ICHG_CTRL = 0x04; +static constexpr uint8_t BQ25186_REG_CHARGECTRL0 = 0x05; +static constexpr uint8_t BQ25186_REG_CHARGECTRL1 = 0x06; +static constexpr uint8_t BQ25186_REG_IC_CTRL = 0x07; +static constexpr uint8_t BQ25186_REG_TMR_ILIM = 0x08; +static constexpr uint8_t BQ25186_REG_SHIP_RST = 0x09; +static constexpr uint8_t BQ25186_REG_SYS_REG = 0x0A; +static constexpr uint8_t BQ25186_REG_TS_CONTROL = 0x0B; +static constexpr uint8_t BQ25186_REG_MASK_ID = 0x0C; +static constexpr size_t BQ25186_CONFIG_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_VBAT_CTRL + 1; + +// Register values for VBAT_CTRL..MASK_ID, packed by code generation +using BQ25186ConfigRegisters = std::array; + +class BQ25186Component : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_setup_registers(const BQ25186ConfigRegisters ®s) { + this->setup_regs_ = regs; + this->configure_on_boot_ = true; + } + bool apply_config(const BQ25186ConfigRegisters ®s); + + protected: + bool write_register_(uint8_t reg, uint8_t value); + bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); + + BQ25186ConfigRegisters setup_regs_{}; + bool configure_on_boot_{false}; +}; + +} // namespace esphome::bq25186 diff --git a/tests/component_tests/bq25186/__init__.py b/tests/component_tests/bq25186/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bq25186/test_pack_registers.py b/tests/component_tests/bq25186/test_pack_registers.py new file mode 100644 index 0000000000..2ebf6087e7 --- /dev/null +++ b/tests/component_tests/bq25186/test_pack_registers.py @@ -0,0 +1,87 @@ +"""Tests for the bq25186 register packer.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.bq25186 import ( + BQ25186_CONFIG_SCHEMA, + CONF_BATTERY_REGULATION_VOLTAGE, + CONF_CHARGE_ENABLED, + CONF_FAST_CHARGE_CURRENT, + CONF_MASK_TS_INTERRUPT, + CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + REG_ICHG_CTRL, + REG_MASK_ID, + REG_SHIP_RST, + REG_VBAT_CTRL, + pack_registers, +) +from esphome.types import ConfigType + +# Datasheet power-on values for VBAT_CTRL..MASK_ID, except that IC_CTRL has the +# watchdog disabled (0x87 instead of 0x84) and MASK_ID carries only its +# configurable bits 7:4 (0x40 instead of 0x41 with the device id). +DEFAULT_REGISTERS = [0x46, 0x05, 0x24, 0xC6, 0x87, 0x4D, 0x11, 0x40, 0x00, 0x40] + + +def _packed(reg: int, config: ConfigType | None = None) -> int: + regs = pack_registers(BQ25186_CONFIG_SCHEMA(config or {})) + return regs[reg - REG_VBAT_CTRL] + + +def test_defaults_match_power_on_values() -> None: + assert pack_registers(BQ25186_CONFIG_SCHEMA({})) == DEFAULT_REGISTERS + + +def test_battery_regulation_voltage_code() -> None: + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "4.1V"}) == 0x3C + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "3.5V"}) == 0x00 + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "4.65V"}) == 0x73 + + +def test_fast_charge_current_code() -> None: + # 1 mA steps from 5 mA to 35 mA + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "5mA"}) == 0x00 + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "20mA"}) == 0x0F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "35mA"}) == 0x1E + # 10 mA steps from 40 mA + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "40mA"}) == 0x1F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "200mA"}) == 0x2F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "1A"}) == 0x7F + + +def test_charge_disabled_sets_bit_7() -> None: + assert _packed(REG_ICHG_CTRL, {CONF_CHARGE_ENABLED: False}) == 0x85 + assert _packed(REG_ICHG_CTRL, {CONF_CHARGE_ENABLED: True}) == 0x05 + + +def test_masked_registers_only_carry_configurable_bits() -> None: + config: ConfigType = { + CONF_PUSH_BUTTON_LONG_PRESS_ACTION: "shutdown_mode", + CONF_MASK_TS_INTERRUPT: True, + } + # SHIP_RST bits 7:5 are reset triggers + ship_rst = _packed(REG_SHIP_RST, config) + assert ship_rst == 0x19 + assert ship_rst & ~0x1F == 0 + # MASK_ID bits 3:0 are the device id + mask_id = _packed(REG_MASK_ID, config) + assert mask_id == 0xC0 + assert mask_id & ~0xF0 == 0 + + +@pytest.mark.parametrize( + "key, value", + [ + (CONF_BATTERY_REGULATION_VOLTAGE, "4.105V"), + (CONF_FAST_CHARGE_CURRENT, "36mA"), + (CONF_FAST_CHARGE_CURRENT, "39mA"), + (CONF_FAST_CHARGE_CURRENT, "49mA"), + (CONF_FAST_CHARGE_CURRENT, "5.5mA"), + (CONF_FAST_CHARGE_CURRENT, "34.6mA"), + (CONF_BATTERY_REGULATION_VOLTAGE, "4.2005V"), + ], +) +def test_values_between_steps_are_rejected(key: str, value: str) -> None: + with pytest.raises(cv.Invalid): + BQ25186_CONFIG_SCHEMA({key: value}) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml new file mode 100644 index 0000000000..9f4b117cd3 --- /dev/null +++ b/tests/components/bq25186/common.yaml @@ -0,0 +1,25 @@ +esphome: + on_boot: + priority: -100 + then: + # Just the defined configuration + - bq25186.apply_configuration: bq25186_charger + # Just the defined configuration with some overrides + - bq25186.apply_configuration: + id: bq25186_charger + battery_regulation_voltage: 4.1V + fast_charge_current: 200mA + +bq25186: + - id: bq25186_charger + configure_on_boot: false + fast_charge_current: 150mA + battery_regulation_voltage: 4.2V + input_current_limit: 500ma + system_regulation: 4.5v + charge_enabled: true + # Second charger with the boot-time write enabled + - id: bq25186_boot + address: 0x6B + configure_on_boot: true + fast_charge_current: 20mA diff --git a/tests/components/bq25186/test.esp32-idf.yaml b/tests/components/bq25186/test.esp32-idf.yaml new file mode 100644 index 0000000000..8854333e24 --- /dev/null +++ b/tests/components/bq25186/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq25186: !include common.yaml diff --git a/tests/components/bq25186/test.esp8266-ard.yaml b/tests/components/bq25186/test.esp8266-ard.yaml new file mode 100644 index 0000000000..88ac37a74f --- /dev/null +++ b/tests/components/bq25186/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bq25186: !include common.yaml From 1e13b7f9736112cfc127e07e9d714874eae22054 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 13 Oct 2026 00:56:28 +0200 Subject: [PATCH 224/268] [tinyusb] Add `tinyusb_keyboard` component (#16079) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/tinyusb/__init__.py | 2 +- .../components/tinyusb/tinyusb_component.cpp | 3 + .../components/tinyusb/tinyusb_component.h | 2 + .../components/tinyusb_keyboard/__init__.py | 110 ++++++++++++++++++ .../components/tinyusb_keyboard/keyboard.cpp | 90 ++++++++++++++ .../components/tinyusb_keyboard/keyboard.h | 66 +++++++++++ tests/components/tinyusb_keyboard/common.yaml | 43 +++++++ .../tinyusb_keyboard/test.esp32-s3-idf.yaml | 2 + 9 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 esphome/components/tinyusb_keyboard/__init__.py create mode 100644 esphome/components/tinyusb_keyboard/keyboard.cpp create mode 100644 esphome/components/tinyusb_keyboard/keyboard.h create mode 100644 tests/components/tinyusb_keyboard/common.yaml create mode 100644 tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d5866286fd..d18ad6e6b4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -605,6 +605,7 @@ esphome/components/thermopro_ble/* @sittner esphome/components/thermostat/* @kbx81 esphome/components/time/* @esphome/core esphome/components/tinyusb/* @kbx81 +esphome/components/tinyusb_keyboard/* @frederikbolding esphome/components/tlc5947/* @rnauber esphome/components/tlc5971/* @IJIJI esphome/components/tm1621/* @Philippe12 diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 586b69d603..92a8e439d2 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -34,7 +34,7 @@ CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # esp_tinyusb's driver install (descriptors_set fails with no class and no # user-provided full_speed_config), which trips the task watchdog before # loop() ever runs. -_USB_CLASS_COMPONENTS = ("usb_cdc_acm",) +_USB_CLASS_COMPONENTS = ("usb_cdc_acm", "tinyusb_keyboard") tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 14291fb328..ba2fbded92 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -26,6 +26,8 @@ void TinyUSB::setup() { } // Start from esp_tinyusb defaults to keep required task settings valid across esp_tinyusb updates. + // Setters write into tusb_cfg_ before setup(), so anything they set has to survive the reset. + const uint8_t *full_speed_config = this->tusb_cfg_.descriptor.full_speed_config; this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; @@ -39,6 +41,7 @@ void TinyUSB::setup() { .device = &this->usb_descriptor_, .string = this->string_descriptor_, .string_count = SIZE, + .full_speed_config = full_speed_config, }; // Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 030356bc03..e9f7283b01 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -49,6 +49,8 @@ class TinyUSB final : public Component { void set_usb_desc_serial(const char *usb_desc_serial) { this->string_descriptor_[SERIAL_NUMBER] = usb_desc_serial; } /// Self-powered device: watch VBUS on this GPIO so a cable pull becomes a detach. void set_vbus_monitor_pin(int pin) { this->vbus_monitor_pin_ = static_cast(pin); } + /// Configuration descriptor from a class component that esp_tinyusb has no built-in default for (HID). + void set_full_speed_config(const uint8_t *descriptor) { this->tusb_cfg_.descriptor.full_speed_config = descriptor; } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english diff --git a/esphome/components/tinyusb_keyboard/__init__.py b/esphome/components/tinyusb_keyboard/__init__.py new file mode 100644 index 0000000000..18b49a95bd --- /dev/null +++ b/esphome/components/tinyusb_keyboard/__init__.py @@ -0,0 +1,110 @@ +import string + +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32 +from esphome.components.esp32 import ( + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + add_idf_sdkconfig_option, +) +from esphome.components.tinyusb import TinyUSB +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_KEY +from esphome.types import ConfigType + +CODEOWNERS = ["@frederikbolding"] +# The keyboard supplies the whole configuration descriptor, with no room for the CDC interfaces +CONFLICTS_WITH = ["usb_cdc_acm"] +DEPENDENCIES = ["tinyusb"] +DOMAIN = "tinyusb_keyboard" + +CONF_MODIFIERS = "modifiers" +CONF_TINYUSB_ID = "tinyusb_id" +CONF_USAGE = "usage" + +# Keyboard page usage ids run without gaps from HID_KEY_A (0x04) through a..z, 1..9 and 0 +HID_KEY_A = 0x04 +KEY_CODES = { + char: HID_KEY_A + index + for index, char in enumerate(string.ascii_lowercase + "1234567890") +} + +tinyusb_keyboard_ns = cg.esphome_ns.namespace("tinyusb_keyboard") +TinyUSBKeyboard = tinyusb_keyboard_ns.class_("TinyUSBKeyboard", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TinyUSBKeyboard), + cv.GenerateID(CONF_TINYUSB_ID): cv.use_id(TinyUSB), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], + ), +) + +_KEY_CODE = cv.Any(cv.enum(KEY_CODES), cv.int_range(min=10, max=255)) + + +def key_code(value: object) -> int: + """A single lowercase letter or digit, or a keyboard usage id. + + Usage ids 0 to 9 are never typed keys (error codes and the letters a to f), so an unquoted + digit is read as that digit. + """ + if isinstance(value, int) and 0 <= value <= 9: + value = str(value) + return _KEY_CODE(value) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + tinyusb = await cg.get_variable(config[CONF_TINYUSB_ID]) + cg.add(tinyusb.set_full_speed_config(tinyusb_keyboard_ns.CONFIGURATION_DESCRIPTOR)) + add_idf_sdkconfig_option("CONFIG_TINYUSB_HID_COUNT", 1) + + +KEYBOARD_ID_SCHEMA = {cv.GenerateID(): cv.use_id(TinyUSBKeyboard)} + +automation.register_apply_action( + "tinyusb_keyboard.press", + automation.maybe_simple_id( + { + **KEYBOARD_ID_SCHEMA, + cv.Required(CONF_KEY): cv.templatable(key_code), + cv.Optional(CONF_MODIFIERS, default=0): cv.templatable(cv.uint8_t), + } + ), + automation.ApplyCall( + "press_key({}, {})", ((CONF_KEY, cg.uint8), (CONF_MODIFIERS, cg.uint8)) + ), +) +automation.register_apply_action( + "tinyusb_keyboard.release", + automation.maybe_simple_id(KEYBOARD_ID_SCHEMA), + automation.ApplyCall("release_keys()"), +) +automation.register_apply_action( + "tinyusb_keyboard.media_press", + automation.maybe_simple_id( + {**KEYBOARD_ID_SCHEMA, cv.Required(CONF_USAGE): cv.templatable(cv.uint16_t)} + ), + automation.ApplyCall("press_media({})", ((CONF_USAGE, cg.uint16),)), +) +automation.register_apply_action( + "tinyusb_keyboard.media_release", + automation.maybe_simple_id(KEYBOARD_ID_SCHEMA), + automation.ApplyCall("release_media()"), +) diff --git a/esphome/components/tinyusb_keyboard/keyboard.cpp b/esphome/components/tinyusb_keyboard/keyboard.cpp new file mode 100644 index 0000000000..6d2be7f7eb --- /dev/null +++ b/esphome/components/tinyusb_keyboard/keyboard.cpp @@ -0,0 +1,90 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + +#include "keyboard.h" +#include "esphome/core/log.h" + +namespace esphome::tinyusb_keyboard { + +ESPHOME_LOG_TAG(TAG, "tinyusb_keyboard"); + +void TinyUSBKeyboard::dump_config() { ESP_LOGCONFIG(TAG, "TinyUSB Keyboard"); } + +void TinyUSBKeyboard::set_keyboard_(uint8_t keycode, uint8_t modifiers) { + this->keycode_ = keycode; + this->modifiers_ = modifiers; + this->keyboard_pending_ = true; + this->flush_(); +} + +void TinyUSBKeyboard::set_consumer_(uint16_t usage) { + this->usage_ = usage; + this->consumer_pending_ = true; + this->flush_(); +} + +void TinyUSBKeyboard::flush_() { + if (!tud_mounted()) { + // No host is listening, and a key held from before it attached would be wrong, so drop the state + ESP_LOGV(TAG, "No host, dropping report"); + this->keyboard_pending_ = false; + this->consumer_pending_ = false; + this->wakeup_requested_ = false; + this->disable_loop(); + return; + } + if (tud_suspended()) { + // A key press wakes a sleeping host, like a real keyboard; the report goes out once the bus resumes. + // Signal once per suspend: a host that did not arm remote wakeup will not answer, and one that did + // must not be signalled on every pass. + if (!this->wakeup_requested_) { + this->wakeup_requested_ = true; + if (!tud_remote_wakeup()) { + ESP_LOGV(TAG, "Host did not allow remote wakeup"); + } + } + this->enable_loop(); + return; + } + this->wakeup_requested_ = false; + // tud_hid_*_report() returns false while the endpoint still holds the previous report, which is + // what happens for a press and a release in one action list. Keep retrying so the release gets out. + if (this->keyboard_pending_) { + uint8_t keycodes[6] = {this->keycode_}; + if (tud_hid_keyboard_report(REPORT_ID_KEYBOARD, this->modifiers_, keycodes)) { + this->keyboard_pending_ = false; + } + } + if (this->consumer_pending_ && !this->keyboard_pending_ && + tud_hid_report(REPORT_ID_CONSUMER, &this->usage_, sizeof(this->usage_))) { + this->consumer_pending_ = false; + } + if (this->keyboard_pending_ || this->consumer_pending_) { + this->enable_loop(); + } else { + this->disable_loop(); + } +} + +void TinyUSBKeyboard::loop() { this->flush_(); } + +} // namespace esphome::tinyusb_keyboard + +// TinyUSB resolves these at link time when HID is enabled +extern "C" { +const uint8_t *tud_hid_descriptor_report_cb(uint8_t /*instance*/) { + return esphome::tinyusb_keyboard::HID_REPORT_DESCRIPTOR; +} + +uint16_t tud_hid_get_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/, + uint8_t * /*buffer*/, uint16_t /*reqlen*/) { + return 0; +} + +// Keyboard LED state from the host; nothing consumes it yet +void tud_hid_set_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/, + const uint8_t * /*buffer*/, uint16_t /*bufsize*/) {} +} + +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tinyusb_keyboard/keyboard.h b/esphome/components/tinyusb_keyboard/keyboard.h new file mode 100644 index 0000000000..4c86ee599c --- /dev/null +++ b/esphome/components/tinyusb_keyboard/keyboard.h @@ -0,0 +1,66 @@ +#pragma once + +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + +#include "esphome/core/component.h" + +#include "tusb.h" +// tusb.h only pulls the HID class in when CFG_TUD_HID is set, which the static analysis build does not do +#include "class/hid/hid_device.h" + +namespace esphome::tinyusb_keyboard { + +static constexpr uint8_t REPORT_ID_KEYBOARD = 1; +static constexpr uint8_t REPORT_ID_CONSUMER = 2; +static constexpr uint8_t ENDPOINT_IN = 0x81; +static constexpr uint8_t POLL_INTERVAL_MS = 10; + +// One HID interface carrying a keyboard and a consumer control (media keys), told apart by report id. +// Report ids rule out the boot protocol, so the interface declares no boot protocol, as TinyUSB's +// composite example does. +inline constexpr uint8_t HID_REPORT_DESCRIPTOR[] = { + TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)), + TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(REPORT_ID_CONSUMER)), +}; + +// esp_tinyusb ships default configuration descriptors for CDC, MSC and NCM only, so the keyboard supplies its own +inline constexpr uint8_t CONFIGURATION_DESCRIPTOR[] = { + TUD_CONFIG_DESCRIPTOR(1, 1, 0, TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + TUD_HID_DESCRIPTOR(0, 0, HID_ITF_PROTOCOL_NONE, sizeof(HID_REPORT_DESCRIPTOR), ENDPOINT_IN, CFG_TUD_HID_EP_BUFSIZE, + POLL_INTERVAL_MS), +}; + +class TinyUSBKeyboard final : public Component { + public: + void setup() override { this->disable_loop(); } + void loop() override; + void dump_config() override; + + /// Press one key (HID usage id) with the given modifier bitmask; every other key is reported released. + void press_key(uint8_t keycode, uint8_t modifiers) { this->set_keyboard_(keycode, modifiers); } + void release_keys() { this->set_keyboard_(0, 0); } + /// Press one consumer control usage, for example 0xE9 for volume up. + void press_media(uint16_t usage) { this->set_consumer_(usage); } + void release_media() { this->set_consumer_(0); } + + protected: + void set_keyboard_(uint8_t keycode, uint8_t modifiers); + void set_consumer_(uint16_t usage); + /// Sends whatever changed; a report the endpoint cannot take yet is retried from loop() + void flush_(); + + // The wanted state of each report, resent until the endpoint accepts it so a release is never lost. + // Only the latest state is kept, so consecutive key actions need a short delay between them. + uint16_t usage_{0}; + uint8_t keycode_{0}; + uint8_t modifiers_{0}; + bool keyboard_pending_{false}; + bool consumer_pending_{false}; + bool wakeup_requested_{false}; +}; + +} // namespace esphome::tinyusb_keyboard + +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/tests/components/tinyusb_keyboard/common.yaml b/tests/components/tinyusb_keyboard/common.yaml new file mode 100644 index 0000000000..8982d61b4a --- /dev/null +++ b/tests/components/tinyusb_keyboard/common.yaml @@ -0,0 +1,43 @@ +tinyusb: + +tinyusb_keyboard: + id: kb + +binary_sensor: + - platform: template + id: kb_letter + on_press: + - tinyusb_keyboard.press: + id: kb + key: a + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_shift_digit + on_press: + - tinyusb_keyboard.press: + id: kb + key: "1" + modifiers: 0x02 # Left Shift + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_raw_code + on_press: + - tinyusb_keyboard.press: + id: kb + key: 0x28 # Enter + modifiers: !lambda return 0; + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_volume_up + on_press: + - tinyusb_keyboard.media_press: + id: kb + usage: 0xE9 # Volume Up + on_release: + - tinyusb_keyboard.media_release: kb diff --git a/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml b/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..763cc106fc --- /dev/null +++ b/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + tinyusb_keyboard: !include common.yaml From 150e32c4bedf3e86d9b4e40b6a98881356b306c3 Mon Sep 17 00:00:00 2001 From: Celsius Aray Date: Mon, 12 Oct 2026 18:56:50 -0400 Subject: [PATCH 225/268] [renogy_inverter_ble] Add Renogy inverter BLE sensor component (#16754) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../renogy_inverter_ble/__init__.py | 40 +++ .../renogy_inverter_ble.cpp | 248 ++++++++++++++++++ .../renogy_inverter_ble/renogy_inverter_ble.h | 59 +++++ .../components/renogy_inverter_ble/sensor.py | 99 +++++++ .../renogy_inverter_ble/common.yaml | 31 +++ .../renogy_inverter_ble/test.esp32-idf.yaml | 3 + 7 files changed, 481 insertions(+) create mode 100644 esphome/components/renogy_inverter_ble/__init__.py create mode 100644 esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp create mode 100644 esphome/components/renogy_inverter_ble/renogy_inverter_ble.h create mode 100644 esphome/components/renogy_inverter_ble/sensor.py create mode 100644 tests/components/renogy_inverter_ble/common.yaml create mode 100644 tests/components/renogy_inverter_ble/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d18ad6e6b4..826c5e7003 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -472,6 +472,7 @@ esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet esphome/components/rd03d/* @jasstrong +esphome/components/renogy_inverter_ble/* @emilioaray-dev esphome/components/resampler/speaker/* @kahrendt esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz diff --git a/esphome/components/renogy_inverter_ble/__init__.py b/esphome/components/renogy_inverter_ble/__init__.py new file mode 100644 index 0000000000..808f27a67a --- /dev/null +++ b/esphome/components/renogy_inverter_ble/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import ble_client +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +AUTO_LOAD = ["sensor"] +CODEOWNERS = ["@emilioaray-dev"] +DEPENDENCIES = ["ble_client"] +DOMAIN = "renogy_inverter_ble" +MULTI_CONF = True + +CONF_RENOGY_INVERTER_BLE_ID = "renogy_inverter_ble_id" + +renogy_inverter_ble_ns = cg.esphome_ns.namespace(DOMAIN) +RenogyInverterBle = renogy_inverter_ble_ns.class_( + "RenogyInverterBle", ble_client.BLEClientNode, cg.PollingComponent +) + +RENOGY_INVERTER_BLE_COMPONENT_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_RENOGY_INVERTER_BLE_ID): cv.use_id(RenogyInverterBle), + } +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RenogyInverterBle), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("30s")) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await ble_client.register_ble_node(var, config) diff --git a/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp new file mode 100644 index 0000000000..4cb43b5efb --- /dev/null +++ b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp @@ -0,0 +1,248 @@ +#include "renogy_inverter_ble.h" + +#ifdef USE_ESP32 + +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::renogy_inverter_ble { + +ESPHOME_LOG_TAG(TAG, "renogy_inverter_ble"); + +static constexpr uint16_t SERVICE_NOTIFY_UUID = 0xFFF0; +static constexpr uint16_t CHAR_NOTIFY_UUID = 0xFFF1; +static constexpr uint16_t SERVICE_WRITE_UUID = 0xFFD0; +static constexpr uint16_t CHAR_WRITE_UUID = 0xFFD1; +static constexpr uint16_t CHAR_INIT_UUID = 0xFFD4; + +static constexpr uint8_t MODBUS_DEVICE_ID = 0x20; +static constexpr uint8_t MODBUS_READ_HOLDING = 0x03; +static constexpr uint16_t REG_MAIN = 4000; +static constexpr uint16_t REG_MAIN_WORDS = 32; +static constexpr uint16_t REG_LOAD = 4408; +static constexpr uint16_t REG_LOAD_WORDS = 6; + +// Watchdog: returns the cycle to IDLE when the inverter stops answering +static constexpr uint32_t CYCLE_TIMEOUT_MS = 3000; +// Gap the inverter needs between two consecutive register reads +static constexpr uint32_t LOAD_READ_DELAY_MS = 300; + +static constexpr uint32_t CYCLE_TIMEOUT_ID = 0; +static constexpr uint32_t LOAD_READ_ID = 1; + +static void publish(sensor::Sensor *s, float value) { + if (s != nullptr) { + s->publish_state(value); + } +} + +void RenogyInverterBle::dump_config() { + ESP_LOGCONFIG(TAG, "Renogy Inverter BLE:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "AC input voltage", this->ac_input_voltage_sensor_); + LOG_SENSOR(" ", "AC output voltage", this->ac_output_voltage_sensor_); + LOG_SENSOR(" ", "AC output current", this->ac_output_current_sensor_); + LOG_SENSOR(" ", "AC output frequency", this->ac_output_frequency_sensor_); + LOG_SENSOR(" ", "Input frequency", this->input_frequency_sensor_); + LOG_SENSOR(" ", "Battery voltage", this->battery_voltage_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Load current", this->load_current_sensor_); + LOG_SENSOR(" ", "Load active power", this->load_active_power_sensor_); + LOG_SENSOR(" ", "Load apparent power", this->load_apparent_power_sensor_); +} + +void RenogyInverterBle::abort_cycle_() { + this->cancel_timeout(CYCLE_TIMEOUT_ID); + this->cancel_timeout(LOAD_READ_ID); + this->frame_len_ = 0; + this->state_ = State::IDLE; +} + +void RenogyInverterBle::read_register_(uint16_t start_register, uint16_t word_count) { + std::array req{MODBUS_DEVICE_ID, + MODBUS_READ_HOLDING, + static_cast(start_register >> 8), + static_cast(start_register), + static_cast(word_count >> 8), + static_cast(word_count)}; + const uint16_t crc = crc16(req.data(), 6); + req[6] = crc & 0xFF; // Modbus sends the CRC low byte first + req[7] = crc >> 8; + + this->frame_len_ = 0; + const esp_err_t status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->write_handle_, + req.size(), req.data(), ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_OK) { + ESP_LOGW(TAG, "write_char (reg %u) failed, status=%d", start_register, status); + this->abort_cycle_(); + } +} + +void RenogyInverterBle::start_cycle_() { + this->set_timeout(CYCLE_TIMEOUT_ID, CYCLE_TIMEOUT_MS, [this]() { + ESP_LOGW(TAG, "Read cycle timed out (state=%u); resetting", static_cast(this->state_)); + this->abort_cycle_(); + }); + if (this->init_handle_ == 0) { + // No init characteristic discovered; try the main read directly + this->state_ = State::MAIN; + this->read_register_(REG_MAIN, REG_MAIN_WORDS); + return; + } + this->state_ = State::INIT; + const esp_err_t status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), + this->init_handle_, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_OK) { + ESP_LOGW(TAG, "init read_char failed, status=%d", status); + this->abort_cycle_(); + } +} + +void RenogyInverterBle::update() { + if (this->node_state != espbt::ClientState::ESTABLISHED) { + ESP_LOGD(TAG, "Not connected yet; skipping poll"); + return; + } + if (this->state_ != State::IDLE) { + ESP_LOGD(TAG, "Previous read cycle still in progress; skipping"); + return; + } + this->start_cycle_(); +} + +void RenogyInverterBle::on_frame_complete_(uint16_t len) { + // A Modbus frame with its CRC appended checks to zero + if (len < 5 || crc16(this->frame_.data(), len) != 0) { + ESP_LOGW(TAG, "CRC mismatch (state=%u, len=%u)", static_cast(this->state_), len); + this->abort_cycle_(); + return; + } + // Exception response: function code with the high bit set, third byte is the exception code + if ((this->frame_[1] & 0x80) != 0) { + ESP_LOGW(TAG, "Modbus exception 0x%02X (state=%u)", this->frame_[2], static_cast(this->state_)); + this->abort_cycle_(); + return; + } + // Only the reply to the outstanding read is accepted: same device, same function, the requested word count + const bool awaiting_reply = this->state_ == State::MAIN || this->state_ == State::LOAD; + const uint16_t expected_words = this->state_ == State::MAIN ? REG_MAIN_WORDS : REG_LOAD_WORDS; + if (!awaiting_reply || this->frame_[0] != MODBUS_DEVICE_ID || this->frame_[1] != MODBUS_READ_HOLDING || + this->frame_[2] != 2 * expected_words) { + ESP_LOGW(TAG, "Unexpected response %02X %02X %02X (state=%u)", this->frame_[0], this->frame_[1], this->frame_[2], + static_cast(this->state_)); + this->abort_cycle_(); + return; + } + auto reg16 = [this](uint8_t i) { return encode_uint16(this->frame_[3 + 2 * i], this->frame_[4 + 2 * i]); }; + + if (this->state_ == State::MAIN) { + publish(this->ac_input_voltage_sensor_, reg16(0) * 0.1f); + publish(this->ac_output_voltage_sensor_, reg16(2) * 0.1f); + publish(this->ac_output_current_sensor_, reg16(3) * 0.01f); + publish(this->ac_output_frequency_sensor_, reg16(4) * 0.01f); + publish(this->battery_voltage_sensor_, reg16(5) * 0.1f); + publish(this->temperature_sensor_, reg16(6) * 0.1f); + publish(this->input_frequency_sensor_, reg16(9) * 0.01f); + this->frame_len_ = 0; + this->set_timeout(LOAD_READ_ID, LOAD_READ_DELAY_MS, [this]() { + this->state_ = State::LOAD; + this->read_register_(REG_LOAD, REG_LOAD_WORDS); + }); + return; + } + publish(this->load_current_sensor_, reg16(0) * 0.01f); + publish(this->load_active_power_sensor_, static_cast(reg16(1))); + publish(this->load_apparent_power_sensor_, static_cast(reg16(2))); + this->abort_cycle_(); +} + +void RenogyInverterBle::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + switch (event) { + case ESP_GATTC_DISCONNECT_EVT: { + this->abort_cycle_(); + this->node_state = espbt::ClientState::IDLE; + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + auto *notify_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_NOTIFY_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_NOTIFY_UUID)); + auto *write_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_WRITE_UUID)); + auto *init_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_INIT_UUID)); + if (notify_chr == nullptr || write_chr == nullptr) { + ESP_LOGE(TAG, "Required characteristics not found; not a Renogy inverter?"); + break; + } + this->notify_handle_ = notify_chr->handle; + this->write_handle_ = write_chr->handle; + this->init_handle_ = (init_chr != nullptr) ? init_chr->handle : 0; + ESP_LOGI(TAG, "handles: notify=%u write=%u init=%u", this->notify_handle_, this->write_handle_, + this->init_handle_); + const esp_err_t status = this->parent_->register_for_notify(this->notify_handle_); + if (status != ESP_OK) { + ESP_LOGW(TAG, "register_for_notify failed, status=%d", status); + } + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + // Other nodes on the same client see this event too + if (param->reg_for_notify.handle != this->notify_handle_) { + break; + } + if (param->reg_for_notify.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "Notification registration failed, status=%d", param->reg_for_notify.status); + break; + } + this->node_state = espbt::ClientState::ESTABLISHED; + ESP_LOGI(TAG, "Connected; will poll the inverter every update interval"); + break; + } + case ESP_GATTC_READ_CHAR_EVT: { + if (this->state_ != State::INIT || param->read.handle != this->init_handle_) { + break; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "init read failed, status=%d", param->read.status); + this->abort_cycle_(); + break; + } + // Init handshake done; the inverter now answers Modbus reads + this->state_ = State::MAIN; + this->read_register_(REG_MAIN, REG_MAIN_WORDS); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (param->notify.handle != this->notify_handle_) { + break; + } + const uint16_t value_len = param->notify.value_len; + if (this->frame_len_ + value_len > MAX_FRAME) { + ESP_LOGW(TAG, "Frame overflow; resetting"); + this->abort_cycle_(); + break; + } + memcpy(&this->frame_[this->frame_len_], param->notify.value, value_len); + this->frame_len_ += value_len; + if (this->frame_len_ < 3) { + break; + } + // An exception frame ([id][func|0x80][exc][crc][crc]) is 5 bytes; a normal response is + // [id][func][byte_count][...data][crc][crc] + const uint16_t expected_len = ((this->frame_[1] & 0x80) != 0) ? 5 : 3 + this->frame_[2] + 2; + if (this->frame_len_ >= expected_len) { + this->on_frame_complete_(expected_len); + } + break; + } + default: + break; + } +} + +} // namespace esphome::renogy_inverter_ble + +#endif // USE_ESP32 diff --git a/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h new file mode 100644 index 0000000000..41bcdddf4b --- /dev/null +++ b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h @@ -0,0 +1,59 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::renogy_inverter_ble { + +namespace espbt = esphome::esp32_ble_tracker; + +// Largest Modbus response: 3 header + 2 * 32 data + 2 CRC bytes +static constexpr size_t MAX_FRAME = 69; + +// Renogy inverter BLE GATT (RIV1230PU). Notify char 0xFFF1 (svc 0xFFF0), write char 0xFFD1 +// (svc 0xFFD0), init char 0xFFD4 (svc 0xFFD0). Modbus device id 0x20, function 0x03. +class RenogyInverterBle : public ble_client::BLEClientNode, public PollingComponent { + SUB_SENSOR(ac_input_voltage) + SUB_SENSOR(ac_output_voltage) + SUB_SENSOR(ac_output_current) + SUB_SENSOR(ac_output_frequency) + SUB_SENSOR(input_frequency) + SUB_SENSOR(battery_voltage) + SUB_SENSOR(temperature) + SUB_SENSOR(load_current) + SUB_SENSOR(load_active_power) + SUB_SENSOR(load_apparent_power) + + public: + void dump_config() override; + void update() override; + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + + protected: + // Read cycle: IDLE -> (update) INIT read(0xFFD4) -> MAIN read(4000) -> LOAD read(4408) -> IDLE + enum class State : uint8_t { IDLE, INIT, MAIN, LOAD }; + + void start_cycle_(); + void read_register_(uint16_t start_register, uint16_t word_count); + void on_frame_complete_(uint16_t len); + void abort_cycle_(); + + State state_{State::IDLE}; + uint8_t frame_len_{0}; + uint16_t notify_handle_{0}; + uint16_t write_handle_{0}; + uint16_t init_handle_{0}; + // Reassembles the Modbus response, which arrives in MTU-sized notification chunks + std::array frame_; +}; + +} // namespace esphome::renogy_inverter_ble + +#endif // USE_ESP32 diff --git a/esphome/components/renogy_inverter_ble/sensor.py b/esphome/components/renogy_inverter_ble/sensor.py new file mode 100644 index 0000000000..9b87cc6503 --- /dev/null +++ b/esphome/components/renogy_inverter_ble/sensor.py @@ -0,0 +1,99 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_VOLTAGE, + CONF_TEMPERATURE, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_HERTZ, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_WATT, +) +from esphome.types import ConfigType + +from . import CONF_RENOGY_INVERTER_BLE_ID, RENOGY_INVERTER_BLE_COMPONENT_SCHEMA + +CODEOWNERS = ["@emilioaray-dev"] +DEPENDENCIES = ["renogy_inverter_ble"] + +CONF_AC_INPUT_VOLTAGE = "ac_input_voltage" +CONF_AC_OUTPUT_VOLTAGE = "ac_output_voltage" +CONF_AC_OUTPUT_CURRENT = "ac_output_current" +CONF_AC_OUTPUT_FREQUENCY = "ac_output_frequency" +CONF_INPUT_FREQUENCY = "input_frequency" +CONF_LOAD_CURRENT = "load_current" +CONF_LOAD_ACTIVE_POWER = "load_active_power" +CONF_LOAD_APPARENT_POWER = "load_apparent_power" + +VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) +CURRENT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, +) +FREQUENCY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = RENOGY_INVERTER_BLE_COMPONENT_SCHEMA.extend( + { + cv.Optional(CONF_AC_INPUT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_FREQUENCY): FREQUENCY_SCHEMA, + cv.Optional(CONF_INPUT_FREQUENCY): FREQUENCY_SCHEMA, + cv.Optional(CONF_BATTERY_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_LOAD_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_LOAD_ACTIVE_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_LOAD_APPARENT_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_RENOGY_INVERTER_BLE_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AC_INPUT_VOLTAGE, hub.set_ac_input_voltage_sensor) + await sensors(CONF_AC_OUTPUT_VOLTAGE, hub.set_ac_output_voltage_sensor) + await sensors(CONF_AC_OUTPUT_CURRENT, hub.set_ac_output_current_sensor) + await sensors(CONF_AC_OUTPUT_FREQUENCY, hub.set_ac_output_frequency_sensor) + await sensors(CONF_INPUT_FREQUENCY, hub.set_input_frequency_sensor) + await sensors(CONF_BATTERY_VOLTAGE, hub.set_battery_voltage_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_LOAD_CURRENT, hub.set_load_current_sensor) + await sensors(CONF_LOAD_ACTIVE_POWER, hub.set_load_active_power_sensor) + await sensors(CONF_LOAD_APPARENT_POWER, hub.set_load_apparent_power_sensor) diff --git a/tests/components/renogy_inverter_ble/common.yaml b/tests/components/renogy_inverter_ble/common.yaml new file mode 100644 index 0000000000..a4f6e8e16f --- /dev/null +++ b/tests/components/renogy_inverter_ble/common.yaml @@ -0,0 +1,31 @@ +ble_client: + - mac_address: 14:9C:EF:01:02:03 + id: inverter_client + +renogy_inverter_ble: + - id: inv0 + ble_client_id: inverter_client + +sensor: + - platform: renogy_inverter_ble + renogy_inverter_ble_id: inv0 + ac_input_voltage: + name: AC input voltage + ac_output_voltage: + name: AC output voltage + ac_output_current: + name: AC output current + ac_output_frequency: + name: AC output frequency + input_frequency: + name: Input frequency + battery_voltage: + name: Battery voltage + temperature: + name: Temperature + load_current: + name: Load current + load_active_power: + name: Load active power + load_apparent_power: + name: Load apparent power diff --git a/tests/components/renogy_inverter_ble/test.esp32-idf.yaml b/tests/components/renogy_inverter_ble/test.esp32-idf.yaml new file mode 100644 index 0000000000..5ef5be8856 --- /dev/null +++ b/tests/components/renogy_inverter_ble/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + renogy_inverter_ble: !include common.yaml From 54fdeb7b1b6c327a4b27bdb14aa1b61e6c93e396 Mon Sep 17 00:00:00 2001 From: Georgi Filipov Date: Tue, 13 Oct 2026 02:01:22 +0300 Subject: [PATCH 226/268] [samsung] Add climate IR samsung support (#8004) Co-authored-by: Georgi Filipov Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/samsung/__init__.py | 1 + esphome/components/samsung/climate.py | 16 ++ esphome/components/samsung/samsung.cpp | 235 ++++++++++++++++++ esphome/components/samsung/samsung.h | 31 +++ tests/components/samsung/common.yaml | 5 + tests/components/samsung/test.esp32-idf.yaml | 4 + .../components/samsung/test.esp8266-ard.yaml | 4 + 8 files changed, 297 insertions(+) create mode 100644 esphome/components/samsung/__init__.py create mode 100644 esphome/components/samsung/climate.py create mode 100644 esphome/components/samsung/samsung.cpp create mode 100644 esphome/components/samsung/samsung.h create mode 100644 tests/components/samsung/common.yaml create mode 100644 tests/components/samsung/test.esp32-idf.yaml create mode 100644 tests/components/samsung/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 826c5e7003..c74ae5616b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -492,6 +492,7 @@ esphome/components/runtime_stats/* @bdraco esphome/components/rx8025t/* @remcom esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti +esphome/components/samsung/* @jorofi esphome/components/scd4x/* @martgras @sjtrny esphome/components/script/* @esphome/core esphome/components/sdl/* @bdm310 @clydebarrow diff --git a/esphome/components/samsung/__init__.py b/esphome/components/samsung/__init__.py new file mode 100644 index 0000000000..fb22727b36 --- /dev/null +++ b/esphome/components/samsung/__init__.py @@ -0,0 +1 @@ +DOMAIN = "samsung" diff --git a/esphome/components/samsung/climate.py b/esphome/components/samsung/climate.py new file mode 100644 index 0000000000..ae59d17f96 --- /dev/null +++ b/esphome/components/samsung/climate.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +from esphome.types import ConfigType + +CODEOWNERS = ["@jorofi"] + +AUTO_LOAD = ["climate_ir"] + +samsung_ns = cg.esphome_ns.namespace("samsung") +SamsungClimate = samsung_ns.class_("SamsungClimate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(SamsungClimate) + + +async def to_code(config: ConfigType) -> None: + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/samsung/samsung.cpp b/esphome/components/samsung/samsung.cpp new file mode 100644 index 0000000000..65f1cd10d6 --- /dev/null +++ b/esphome/components/samsung/samsung.cpp @@ -0,0 +1,235 @@ +#include "samsung.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::samsung { + +ESPHOME_LOG_TAG(TAG, "samsung.climate"); + +static constexpr uint32_t CARRIER_FREQUENCY_HZ = 38000; +static constexpr uint32_t HEADER_MARK_US = 3000; +static constexpr uint32_t HEADER_SPACE_US = 9000; +static constexpr uint32_t BIT_MARK_US = 500; +static constexpr uint32_t ONE_SPACE_US = 1500; +static constexpr uint32_t ZERO_SPACE_US = 500; +static constexpr uint32_t SECTION_SPACE_US = 2000; + +static constexpr uint8_t EXTENDED_LENGTH = 3 * SAMSUNG_AC_SECTION_LENGTH; + +// A powered-on unit at 24 °C, cool mode, fan auto, swing off (from IRremoteESP8266); checksums are refilled on send +static const uint8_t RESET_STATE[SAMSUNG_AC_STATE_LENGTH] PROGMEM = {0x02, 0x92, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x01, 0xE2, 0xFE, 0x71, 0x80, 0x11, 0xF0}; +// Middle section of a power on/off frame: an all-zero timer block +static const uint8_t TIMER_SECTION[SAMSUNG_AC_SECTION_LENGTH] PROGMEM = {0x01, 0xD2, 0x0F, 0x00, 0x00, 0x00, 0x00}; + +// Where each setting lives in the 14 byte state +struct Field { + uint8_t byte; + uint8_t shift; + uint8_t mask; +}; +static constexpr Field POWER_1{6, 4, 0b11}; +static constexpr Field SWING{9, 4, 0b111}; +static constexpr Field TEMP{11, 4, 0b1111}; +static constexpr Field FAN{12, 1, 0b111}; +static constexpr Field MODE{12, 4, 0b111}; +static constexpr Field POWER_2{13, 4, 0b11}; +static constexpr uint8_t POWER_ON = 0b11; + +static uint8_t get_field(const uint8_t *state, Field field) { return (state[field.byte] >> field.shift) & field.mask; } +static void set_field(uint8_t *state, Field field, uint8_t value) { + state[field.byte] = (state[field.byte] & ~(field.mask << field.shift)) | ((value & field.mask) << field.shift); +} +static bool is_powered_on(const uint8_t *state) { + return get_field(state, POWER_1) == POWER_ON && get_field(state, POWER_2) == POWER_ON; +} + +struct Code { + uint8_t value; // a climate enum, all of which are uint8_t + uint8_t code; +}; +// The first entry of each table is the fallback in both directions +static constexpr Code MODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, 0}, {climate::CLIMATE_MODE_COOL, 1}, {climate::CLIMATE_MODE_DRY, 2}, + {climate::CLIMATE_MODE_FAN_ONLY, 3}, {climate::CLIMATE_MODE_HEAT, 4}, +}; +static constexpr Code FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, 0}, + {climate::CLIMATE_FAN_LOW, 2}, + {climate::CLIMATE_FAN_MEDIUM, 4}, + {climate::CLIMATE_FAN_HIGH, 5}, +}; +static constexpr Code SWINGS[] PROGMEM = { + {climate::CLIMATE_SWING_OFF, 0b111}, + {climate::CLIMATE_SWING_VERTICAL, 0b010}, + {climate::CLIMATE_SWING_HORIZONTAL, 0b011}, + {climate::CLIMATE_SWING_BOTH, 0b100}, +}; + +// Returns the entry whose value (or code) matches key, else the first entry. Entries are copied out of PROGMEM. +static Code find_entry(const Code *table, size_t count, uint8_t key, bool match_code) { + Code entry{}; + for (size_t i = 0; i < count; i++) { + progmem_memcpy(&entry, &table[i], sizeof(entry)); + if ((match_code ? entry.code : entry.value) == key) + return entry; + } + progmem_memcpy(&entry, table, sizeof(entry)); + return entry; +} +template static uint8_t to_code(const Code (&table)[N], uint8_t value) { + return find_entry(table, N, value, false).code; +} +template static T from_code(const Code (&table)[N], uint8_t code) { + return static_cast(find_entry(table, N, code, true).value); +} + +// Inverted count of the one bits in a section, skipping the two nibbles that hold the checksum itself. +// See https://github.com/crankyoldgit/IRremoteESP8266/issues/1538#issuecomment-894645947 +static uint8_t section_checksum(const uint8_t *section) { + uint32_t ones = + __builtin_popcount(section[0]) + __builtin_popcount(section[1] & 0x0F) + __builtin_popcount(section[2] & 0xF0); + for (uint8_t i = 3; i < SAMSUNG_AC_SECTION_LENGTH; i++) { + ones += __builtin_popcount(section[i]); + } + return ~static_cast(ones); +} +static uint8_t stored_checksum(const uint8_t *section) { return (section[1] >> 4) | (section[2] << 4); } +static void store_checksum(uint8_t *section) { + const uint8_t sum = section_checksum(section); + section[1] = (section[1] & 0x0F) | (sum << 4); + section[2] = (section[2] & 0xF0) | (sum >> 4); +} + +SamsungClimate::SamsungClimate() + : climate_ir::ClimateIR( + SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_TEMP_MAX, 1.0f, true, true, + {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}) { + progmem_memcpy(this->state_.data(), RESET_STATE, SAMSUNG_AC_STATE_LENGTH); + // The unit's power state is unknown at boot, so the first "on" command sends a power frame + set_field(this->state_.data(), POWER_1, 0); + set_field(this->state_.data(), POWER_2, 0); +} + +void SamsungClimate::transmit_state() { + uint8_t *state = this->state_.data(); + const bool was_on = is_powered_on(state); + const bool on = this->mode != climate::CLIMATE_MODE_OFF; + + if (on) { + progmem_memcpy(state, RESET_STATE, SAMSUNG_AC_STATE_LENGTH); + set_field(state, MODE, to_code(MODES, this->mode)); + } // else keep the last mode so the power off frame still carries a valid state + + const uint8_t temp = clamp(this->target_temperature, SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_TEMP_MAX); + set_field(state, TEMP, temp - SAMSUNG_AC_TEMP_MIN); + set_field(state, SWING, to_code(SWINGS, this->swing_mode)); + set_field(state, FAN, to_code(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO))); + set_field(state, POWER_1, on ? POWER_ON : 0); + set_field(state, POWER_2, on ? POWER_ON : 0); + + if (on != was_on) { + // A power change is sent as an extended frame with the timer block between the two state sections + uint8_t timer[SAMSUNG_AC_SECTION_LENGTH]; + progmem_memcpy(timer, TIMER_SECTION, SAMSUNG_AC_SECTION_LENGTH); + const uint8_t *sections[] = {state, timer, state + SAMSUNG_AC_SECTION_LENGTH}; + this->send_sections_(sections, 3); + } else { + const uint8_t *sections[] = {state, state + SAMSUNG_AC_SECTION_LENGTH}; + this->send_sections_(sections, 2); + } +} + +void SamsungClimate::send_sections_(const uint8_t *const *sections, uint8_t count) { + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + // Header(2) + (sections - 1) * separator(4) + bits(2 each) + trailing mark(1) + data->reserve(2 + (count - 1) * 4 + count * SAMSUNG_AC_SECTION_LENGTH * 8 * 2 + 1); + data->set_carrier_frequency(CARRIER_FREQUENCY_HZ); + data->item(HEADER_MARK_US, HEADER_SPACE_US); + + for (uint8_t s = 0; s < count; s++) { + if (s != 0) { + data->item(BIT_MARK_US, SECTION_SPACE_US); + data->item(HEADER_MARK_US, HEADER_SPACE_US); + } + uint8_t section[SAMSUNG_AC_SECTION_LENGTH]; + memcpy(section, sections[s], SAMSUNG_AC_SECTION_LENGTH); + store_checksum(section); + for (uint8_t byte : section) { + for (uint8_t bit = 0; bit < 8; bit++, byte >>= 1) { + data->item(BIT_MARK_US, (byte & 1) ? ONE_SPACE_US : ZERO_SPACE_US); + } + } + } + + data->mark(BIT_MARK_US); + transmit.perform(); +} + +bool SamsungClimate::on_receive(remote_base::RemoteReceiveData data) { + if (!data.expect_item(HEADER_MARK_US, HEADER_SPACE_US)) { + return false; + } + + // Decode into a local buffer so a bad frame leaves the stored state untouched + uint8_t frame[EXTENDED_LENGTH] = {0}; + uint8_t length = EXTENDED_LENGTH; + for (uint8_t i = 0; i < EXTENDED_LENGTH; i++) { + if (i != 0 && i % SAMSUNG_AC_SECTION_LENGTH == 0) { + const bool have_separator = + data.expect_item(BIT_MARK_US, SECTION_SPACE_US) && data.expect_item(HEADER_MARK_US, HEADER_SPACE_US); + if (!have_separator) { + // A third section is optional; a missing separator after the second one ends the frame + if (i >= SAMSUNG_AC_STATE_LENGTH) { + length = i; + break; + } + ESP_LOGW(TAG, "Missing section separator before byte %u", i); + return false; + } + } + for (uint8_t bit = 0; bit < 8; bit++) { + if (data.expect_item(BIT_MARK_US, ONE_SPACE_US)) { + frame[i] |= 1 << bit; + } else if (!data.expect_item(BIT_MARK_US, ZERO_SPACE_US)) { + ESP_LOGW(TAG, "Bad bit %u in byte %u", bit, i); + return false; + } + } + } + if (!data.expect_mark(BIT_MARK_US)) { + ESP_LOGVV(TAG, "Footer fail"); + return false; + } + for (uint8_t offset = 0; offset < length; offset += SAMSUNG_AC_SECTION_LENGTH) { + if (stored_checksum(frame + offset) != section_checksum(frame + offset)) { + ESP_LOGW(TAG, "Checksum mismatch in section %u", offset / SAMSUNG_AC_SECTION_LENGTH); + return false; + } + } + + // In an extended frame the middle section is the timer block and the last one the second state section + uint8_t *state = this->state_.data(); + memcpy(state, frame, SAMSUNG_AC_SECTION_LENGTH); + memcpy(state + SAMSUNG_AC_SECTION_LENGTH, frame + length - SAMSUNG_AC_SECTION_LENGTH, SAMSUNG_AC_SECTION_LENGTH); + + this->swing_mode = from_code(SWINGS, get_field(state, SWING)); + this->target_temperature = get_field(state, TEMP) + SAMSUNG_AC_TEMP_MIN; + this->fan_mode = from_code(FANS, get_field(state, FAN)); + this->mode = + is_powered_on(state) ? from_code(MODES, get_field(state, MODE)) : climate::CLIMATE_MODE_OFF; + + ESP_LOGD(TAG, "Received %u byte frame, power %s", length, ONOFF(is_powered_on(state))); + this->publish_state(); + return true; +} + +} // namespace esphome::samsung diff --git a/esphome/components/samsung/samsung.h b/esphome/components/samsung/samsung.h new file mode 100644 index 0000000000..608cffd028 --- /dev/null +++ b/esphome/components/samsung/samsung.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/components/climate/climate_mode.h" +#include "esphome/components/climate_ir/climate_ir.h" + +#include + +namespace esphome::samsung { + +static constexpr uint8_t SAMSUNG_AC_TEMP_MIN = 16; +static constexpr uint8_t SAMSUNG_AC_TEMP_MAX = 30; +// A frame is made of 7 byte sections; the normal state is two sections and a power change adds a timer section +static constexpr uint8_t SAMSUNG_AC_SECTION_LENGTH = 7; +static constexpr uint8_t SAMSUNG_AC_STATE_LENGTH = 2 * SAMSUNG_AC_SECTION_LENGTH; + +class SamsungClimate : public climate_ir::ClimateIR { + public: + SamsungClimate(); + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + /// Send the given sections as one frame, each with its checksum filled in + void send_sections_(const uint8_t *const *sections, uint8_t count); + + // The last state sent or received; its power bits say whether the next frame has to switch power + std::array state_{}; +}; + +} // namespace esphome::samsung diff --git a/tests/components/samsung/common.yaml b/tests/components/samsung/common.yaml new file mode 100644 index 0000000000..534500ca3a --- /dev/null +++ b/tests/components/samsung/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: samsung + name: SAMSUNG Climate + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/samsung/test.esp32-idf.yaml b/tests/components/samsung/test.esp32-idf.yaml new file mode 100644 index 0000000000..193f830e47 --- /dev/null +++ b/tests/components/samsung/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 + samsung: !include common.yaml diff --git a/tests/components/samsung/test.esp8266-ard.yaml b/tests/components/samsung/test.esp8266-ard.yaml new file mode 100644 index 0000000000..451bf93b94 --- /dev/null +++ b/tests/components/samsung/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + samsung: !include common.yaml From 81c275777d9212622f64e295c99fcd5b578aa115 Mon Sep 17 00:00:00 2001 From: Dennis Gaida <2392217+DennisGaida@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:08:41 +0200 Subject: [PATCH 227/268] [bmi270] Add optional BMM150 magnetometer support via aux interface (#18436) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/bmi270/bmi270.cpp | 195 ++++++++++++++++-- esphome/components/bmi270/bmi270.h | 118 +++++++++-- esphome/components/bmi270/bmm150_aux.h | 123 +++++++++++ esphome/components/bmi270/motion.py | 17 ++ esphome/components/bmi270/sensor.py | 88 ++++++-- esphome/core/defines.h | 1 + tests/components/bmi270/common-plain.yaml | 11 + tests/components/bmi270/common.yaml | 16 ++ .../bmi270/test-plain.esp32-idf.yaml | 3 + tests/components/bmi270/test.esp32-idf.yaml | 4 +- 10 files changed, 518 insertions(+), 58 deletions(-) create mode 100644 esphome/components/bmi270/bmm150_aux.h create mode 100644 tests/components/bmi270/common-plain.yaml create mode 100644 tests/components/bmi270/test-plain.esp32-idf.yaml diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index a1709c1fa5..37f718fb7e 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -5,12 +5,16 @@ namespace esphome::bmi270 { +#ifdef USE_BMI270_BMM150 +using namespace bmm150; // NOLINT - sole consumer of the BMM150 aux-device definitions +#endif + ESPHOME_LOG_TAG(TAG, "bmi270"); #if defined(USE_ARDUINO) && !defined(USE_ESP32) -static const size_t MAX_I2C_BUFFER_SIZE = 32; +static constexpr size_t MAX_I2C_BUFFER_SIZE = 32; #else -static const size_t MAX_I2C_BUFFER_SIZE = 256; +static constexpr size_t MAX_I2C_BUFFER_SIZE = 256; #endif // Configuration blob upload @@ -19,12 +23,12 @@ static const size_t MAX_I2C_BUFFER_SIZE = 256; bool BMI270Component::load_config_file_() { // 1. Disable advanced power-save so the config port is accessible - if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00)) + if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_ADV_POWER_SAVE_OFF)) return false; delay(1); // 2. Prepare config load: write 0x00 to INIT_CTRL to start - if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00)) + if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_START)) return false; // 3. Burst-write the config in pages @@ -50,7 +54,7 @@ bool BMI270Component::load_config_file_() { } // 4. Signal end of config load - if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01)) + if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_DONE)) return false; delay(20); // spec: wait ≥20 ms for init to complete @@ -58,7 +62,7 @@ bool BMI270Component::load_config_file_() { uint8_t status = 0; if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status)) return false; - if ((status & 0x0F) != 0x01) { + if ((status & BMI270_INTERNAL_STATUS_MASK) != BMI270_INTERNAL_STATUS_INIT_OK) { ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status); return false; } @@ -84,7 +88,7 @@ void BMI270Component::setup() { ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); // 2. Soft-reset via CMD register (0x7E = 0xB6) - if (!this->write_byte(0x7E, 0xB6)) { + if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) { this->mark_failed(); return; } @@ -100,7 +104,8 @@ void BMI270Component::setup() { // 5. Configure accelerometer // ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1) - uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7); + uint8_t acc_conf = + (uint8_t) (accel_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) | BMI270_ACC_CONF_PERF_MODE; if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) { this->mark_failed(); return; @@ -112,7 +117,8 @@ void BMI270Component::setup() { // 6. Configure gyroscope // GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1) - uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7); + uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) | + BMI270_GYR_CONF_NOISE_PERF | BMI270_GYR_CONF_FILTER_PERF; if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) { this->mark_failed(); return; @@ -124,7 +130,7 @@ void BMI270Component::setup() { // 7. Enable accelerometer, gyroscope, and temperature sensor // PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1] - if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) { + if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU)) { this->mark_failed(); return; } @@ -132,14 +138,133 @@ void BMI270Component::setup() { // 8. Re-enable advanced power save (optional; keeps current low between reads) // Disabled here for simplicity – leave in performance mode - if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup + if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_FIFO_SELF_WAKEUP)) { // bit1 = fifo_self_wakeup this->mark_failed(); return; } + // 9. Optionally bring up whatever is wired to the aux interface. + // This is entirely additive: it is off by default, and failure only disables + // magnetometer reporting rather than the whole component. +#ifdef USE_BMI270_BMM150 + if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { + this->magnetometer_ready_ = this->setup_magnetometer_(); + if (!this->magnetometer_ready_) { + ESP_LOGW(TAG, "BMM150 magnetometer setup failed; continuing without it"); + } + } +#endif + ESP_LOGCONFIG(TAG, "BMI270 initialised successfully"); } +#ifdef USE_BMI270_BMM150 +bool BMI270Component::wait_for_aux_idle_(uint32_t timeout_ms) { + // The STATUS aux-busy bit is set while an aux transaction is in flight. Aux transaction + // duration depends on the attached device's own timing, so poll with a bounded + // timeout instead of guessing a fixed delay. + uint32_t start = millis(); + while (true) { + uint8_t status = 0; + if (!this->read_byte(BMI270_REG_STATUS, &status)) + return false; + if ((status & BMI270_STATUS_AUX_BUSY) == 0) + return true; + if (millis() - start >= timeout_ms) + return false; + delay(1); + } +} + +bool BMI270Component::write_aux_register_(uint8_t reg, uint8_t value) { + if (!this->write_byte(BMI270_REG_AUX_WR_DATA, value)) + return false; + if (!this->write_byte(BMI270_REG_AUX_WR_ADDR, reg)) + return false; + return this->wait_for_aux_idle_(20); +} + +bool BMI270Component::read_aux_block_(uint8_t reg, uint8_t *buf) { + if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, reg)) + return false; + if (!this->wait_for_aux_idle_(20)) + return false; + return this->read_bytes(BMI270_REG_AUX_X_LSB, buf, BMI270_AUX_DATA_LEN); +} + +bool BMI270Component::read_magnetometer_trim_() { + uint8_t regs[BMM150_TRIM_BLOCKS * BMM150_TRIM_BLOCK_LEN]; + for (uint8_t i = 0; i < BMM150_TRIM_BLOCKS; i++) { + if (!this->read_aux_block_(BMM150_REG_TRIM_START + i * BMM150_TRIM_BLOCK_LEN, regs + i * BMM150_TRIM_BLOCK_LEN)) + return false; + } + this->mag_trim_ = bmm150_parse_trim(regs); + return true; +} + +bool BMI270Component::setup_magnetometer_() { + // Sequence adapted from the Bosch BMI270 aux-interface protocol: enable the secondary + // I2C master in manual mode, point it at the BMM150's address, power the BMM150 up + // and soft-reset it, verify its chip ID, read its factory trim values, put it in + // normal/30Hz mode, then switch the aux interface to auto-burst mode so BMI270 keeps + // AUX_X/Y/Z/R refreshed from the BMM150's data registers. + ESP_LOGD(TAG, "Setting up BMM150 magnetometer via BMI270 aux interface..."); + + if (!this->write_byte(BMI270_REG_IF_CONF, BMI270_IF_CONF_AUX_IF_EN)) + return false; + if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_MANUAL_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8)) + return false; + if (!this->write_byte(BMI270_REG_AUX_DEV_ID, (uint8_t) (this->aux_device_address_ << 1))) + return false; + + // Suspend -> sleep, then a soft reset so the BMM150 starts from a known state even if it + // stayed powered across a restart. The chip ID is only readable once the power bit is set. + if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_POWER_ON)) { + ESP_LOGW(TAG, "BMM150 power-on write timed out"); + return false; + } + delay(BMM150_START_UP_TIME_MS); + if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_SOFT_RESET)) { + ESP_LOGW(TAG, "BMM150 soft-reset write timed out"); + return false; + } + delay(BMM150_SOFT_RESET_TIME_MS); + + uint8_t chip_id[BMI270_AUX_DATA_LEN]; + if (!this->read_aux_block_(BMM150_REG_CHIP_ID, chip_id)) { + ESP_LOGW(TAG, "BMM150 chip-ID read failed"); + return false; + } + if (chip_id[0] != BMM150_CHIP_ID_VALUE) { + ESP_LOGW(TAG, "BMM150 not detected (chip ID 0x%02X, expected 0x%02X)", chip_id[0], BMM150_CHIP_ID_VALUE); + return false; + } + + if (!this->read_magnetometer_trim_()) { + ESP_LOGW(TAG, "BMM150 trim data read failed"); + return false; + } + + if (!this->write_aux_register_(BMM150_REG_OP_MODE, BMM150_CMD_NORMAL_MODE_ODR_30HZ)) { + ESP_LOGW(TAG, "BMM150 mode write timed out"); + return false; + } + + // Switch to auto-burst mode: BMI270 will keep polling 8 bytes from the BMM150's + // data registers (0x42) into its own AUX_X/Y/Z/R registers automatically. + if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_FCU_WRITE_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8 | + BMI270_AUX_IF_CONF_RD_BURST_8)) + return false; + if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, BMM150_REG_DATA_X_LSB)) + return false; + if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU | BMI270_PWR_CTRL_AUX_EN)) + return false; + + ESP_LOGD(TAG, "BMM150 magnetometer ready"); + return true; +} +#endif // USE_BMI270_BMM150 + void BMI270Component::dump_config() { ESP_LOGCONFIG(TAG, "BMI270 IMU:"); LOG_I2C_DEVICE(this); @@ -153,6 +278,12 @@ void BMI270Component::dump_config() { ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); +#ifdef USE_BMI270_BMM150 + if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { + ESP_LOGCONFIG(TAG, " Aux device : BMM150 magnetometer (%s)", + this->magnetometer_ready_ ? LOG_STR_LITERAL("ready") : LOG_STR_LITERAL("NOT DETECTED")); + } +#endif MotionComponent::dump_config(); } @@ -163,9 +294,22 @@ bool BMI270Component::update_data(motion::MotionData &data) { if (this->is_failed()) return false; - // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) - uint8_t raw_data[REG_READ_LEN]; - if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) { + // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) + // When the magnetometer is active, the 8 aux data bytes (0x04–0x0B) sit directly before + // the accel data, so one burst read from AUX_X_LSB fetches both. +#ifdef USE_BMI270_BMM150 + const bool read_mag = this->magnetometer_ready_ && !this->magnetometer_callback_.empty(); + uint8_t buf[BMI270_AUX_DATA_LEN + REG_READ_LEN]; + const uint8_t *mag_raw = buf; + const uint8_t *raw_data = read_mag ? buf + BMI270_AUX_DATA_LEN : buf; + const bool read_ok = read_mag ? this->read_bytes(BMI270_REG_AUX_X_LSB, buf, sizeof(buf)) + : this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN); +#else + uint8_t buf[REG_READ_LEN]; + const uint8_t *raw_data = buf; + const bool read_ok = this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN); +#endif + if (!read_ok) { ESP_LOGW(TAG, "Failed to read IMU data"); return false; } @@ -195,14 +339,21 @@ bool BMI270Component::update_data(motion::MotionData &data) { data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale; data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale; - if (this->temperature_callback_.empty()) - return true; - // Temperature: registers 0x22–0x23 - // Formula from datasheet: T[°C] = raw / 512 + 23 - static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; - int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); - float temperature = (raw_t / 512.0f) + 23.0f; - this->temperature_callback_.call(temperature); + if (!this->temperature_callback_.empty()) { + // Temperature: registers 0x22–0x23 + // Formula from datasheet: T[°C] = raw / 512 + 23 + static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + float temperature = (raw_t / 512.0f) + 23.0f; + this->temperature_callback_.call(temperature); + } + +#ifdef USE_BMI270_BMM150 + if (read_mag) { + const BMM150Data mag_data = bmm150_convert(this->mag_trim_, mag_raw); + this->magnetometer_callback_.call(mag_data); + } +#endif return true; } diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index 56d6a60952..a1fefdffe8 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -4,35 +4,81 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/defines.h" +#ifdef USE_BMI270_BMM150 +#include "bmm150_aux.h" +#endif #include namespace esphome::bmi270 { // Register map -static const uint8_t BMI270_REG_CHIP_ID = 0x00; -static const uint8_t BMI270_REG_ERR_REG = 0x02; -static const uint8_t BMI270_REG_STATUS = 0x03; -static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB -static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB -static const uint8_t BMI270_REG_TEMP_0 = 0x22; -static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) +static constexpr uint8_t BMI270_REG_CHIP_ID = 0x00; +static constexpr uint8_t BMI270_REG_ERR_REG = 0x02; +static constexpr uint8_t BMI270_REG_STATUS = 0x03; +static constexpr uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB +static constexpr uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB +static constexpr uint8_t BMI270_REG_TEMP_0 = 0x22; +static constexpr uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) static constexpr uint8_t REG_READ_LEN = BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 + 1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4) -static const uint8_t BMI270_REG_PWR_CONF = 0x7C; -static const uint8_t BMI270_REG_PWR_CTRL = 0x7D; -static const uint8_t BMI270_REG_INIT_CTRL = 0x59; -static const uint8_t BMI270_REG_INIT_DATA = 0x5E; -static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; -static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; -static const uint8_t BMI270_REG_ACC_CONF = 0x40; -static const uint8_t BMI270_REG_ACC_RANGE = 0x41; -static const uint8_t BMI270_REG_GYR_CONF = 0x42; -static const uint8_t BMI270_REG_GYR_RANGE = 0x43; +static constexpr uint8_t BMI270_REG_PWR_CONF = 0x7C; +static constexpr uint8_t BMI270_REG_PWR_CTRL = 0x7D; +static constexpr uint8_t BMI270_REG_INIT_CTRL = 0x59; +static constexpr uint8_t BMI270_REG_INIT_DATA = 0x5E; +static constexpr uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; +static constexpr uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; +static constexpr uint8_t BMI270_REG_ACC_CONF = 0x40; +static constexpr uint8_t BMI270_REG_ACC_RANGE = 0x41; +static constexpr uint8_t BMI270_REG_GYR_CONF = 0x42; +static constexpr uint8_t BMI270_REG_GYR_RANGE = 0x43; -static const uint8_t BMI270_CHIP_ID_VALUE = 0x24; +static constexpr uint8_t BMI270_CHIP_ID_VALUE = 0x24; + +// Auxiliary (secondary I2C master) interface registers. +// Used to optionally drive a BMM150 magnetometer wired to the BMI270's AUX pins +// instead of the main I2C bus - the BMM150 has no address of its own on that bus. +static constexpr uint8_t BMI270_REG_AUX_X_LSB = 0x04; // start of aux data: X,Y,Z,R (8 bytes) +static constexpr uint8_t BMI270_REG_AUX_DEV_ID = 0x4B; +static constexpr uint8_t BMI270_REG_AUX_IF_CONF = 0x4C; +static constexpr uint8_t BMI270_REG_AUX_RD_ADDR = 0x4D; +static constexpr uint8_t BMI270_REG_AUX_WR_ADDR = 0x4E; +static constexpr uint8_t BMI270_REG_AUX_WR_DATA = 0x4F; +static constexpr uint8_t BMI270_REG_IF_CONF = 0x6B; +static constexpr uint8_t BMI270_REG_CMD = 0x7E; + +// Register values and bitfields +static constexpr uint8_t BMI270_CMD_SOFT_RESET = 0xB6; +static constexpr uint8_t BMI270_PWR_CONF_ADV_POWER_SAVE_OFF = 0x00; +static constexpr uint8_t BMI270_PWR_CONF_FIFO_SELF_WAKEUP = 0x02; +static constexpr uint8_t BMI270_INIT_CTRL_LOAD_START = 0x00; +static constexpr uint8_t BMI270_INIT_CTRL_LOAD_DONE = 0x01; +static constexpr uint8_t BMI270_INTERNAL_STATUS_MASK = 0x0F; +static constexpr uint8_t BMI270_INTERNAL_STATUS_INIT_OK = 0x01; +static constexpr uint8_t BMI270_CONF_BWP_NORMAL = 0x02; // filter bandwidth, bits 5:4 +static constexpr uint8_t BMI270_CONF_BWP_SHIFT = 4; +static constexpr uint8_t BMI270_ACC_CONF_PERF_MODE = 0x80; +static constexpr uint8_t BMI270_GYR_CONF_NOISE_PERF = 0x40; +static constexpr uint8_t BMI270_GYR_CONF_FILTER_PERF = 0x80; +static constexpr uint8_t BMI270_STATUS_AUX_BUSY = 0x04; +static constexpr uint8_t BMI270_IF_CONF_AUX_IF_EN = 0x20; +static constexpr uint8_t BMI270_PWR_CTRL_AUX_EN = 0x01; +static constexpr uint8_t BMI270_PWR_CTRL_ACC_EN = 0x02; +static constexpr uint8_t BMI270_PWR_CTRL_GYR_EN = 0x04; +static constexpr uint8_t BMI270_PWR_CTRL_TEMP_EN = 0x08; +static constexpr uint8_t BMI270_PWR_CTRL_IMU = + BMI270_PWR_CTRL_ACC_EN | BMI270_PWR_CTRL_GYR_EN | BMI270_PWR_CTRL_TEMP_EN; +static constexpr uint8_t BMI270_AUX_IF_CONF_MANUAL_EN = 0x80; +static constexpr uint8_t BMI270_AUX_IF_CONF_FCU_WRITE_EN = 0x40; +static constexpr uint8_t BMI270_AUX_IF_CONF_MAN_RD_BURST_8 = 0x0C; // bits 3:2 = 3 +static constexpr uint8_t BMI270_AUX_IF_CONF_RD_BURST_8 = 0x03; // bits 1:0 = 3 + +// AUX_X_LSB..AUX_R_MSB (8 bytes) sit directly before the accel data, so one burst can read both. +static constexpr uint8_t BMI270_AUX_DATA_LEN = 8; +static_assert(BMI270_REG_AUX_X_LSB + BMI270_AUX_DATA_LEN == BMI270_REG_DATA_8, "aux data must precede accel data"); // Accelerometer range options enum BMI270AccelRange : uint8_t { @@ -75,7 +121,14 @@ enum BMI270GyroODR : uint8_t { BMI270_GYRO_ODR_3200 = 0x0D, }; -// ---Data class +#ifdef USE_BMI270_BMM150 +// What is wired to this instance's auxiliary (secondary I2C master) interface; another +// instance in the same build may have nothing attached. +enum BMI270AuxDevice : uint8_t { + BMI270_AUX_DEVICE_NONE = 0, + BMI270_AUX_DEVICE_BMM150 = 1, +}; +#endif // Main component class class BMI270Component final : public motion::MotionComponent, public i2c::I2CDevice { @@ -91,18 +144,43 @@ class BMI270Component final : public motion::MotionComponent, public i2c::I2CDev void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; } void set_gyro_odr(BMI270GyroODR o) { this->gyro_odr_ = o; } template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } +#ifdef USE_BMI270_BMM150 + void set_aux_device(BMI270AuxDevice device) { this->aux_device_ = device; } + void set_aux_device_address(uint8_t address) { this->aux_device_address_ = address; } + template void add_magnetometer_listener(F &&cb) { this->magnetometer_callback_.add(std::forward(cb)); } +#endif protected: bool update_data(motion::MotionData &data) override; bool load_config_file_(); +#ifdef USE_BMI270_BMM150 + // Brings up the BMM150 over the BMI270's aux interface. Optional: on failure this only + // disables magnetometer reporting, it does not fail the whole component (accel/gyro still work). + bool setup_magnetometer_(); + // Blocking poll of the aux-transaction-busy bit; aux transaction time depends on the + // attached device, so this is bounded by retries/timeout rather than a fixed delay. + bool wait_for_aux_idle_(uint32_t timeout_ms); + // Manual-mode aux read of 8 bytes starting at `reg` on the aux device. + bool read_aux_block_(uint8_t reg, uint8_t *buf); + // Manual-mode aux write of one byte to `reg` on the aux device. + bool write_aux_register_(uint8_t reg, uint8_t value); + // Reads the BMM150 factory trim values used to compensate its samples. + bool read_magnetometer_trim_(); +#endif // Config BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G}; BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100}; BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000}; BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200}; - LazyCallbackManager temperature_callback_{}; +#ifdef USE_BMI270_BMM150 + LazyCallbackManager magnetometer_callback_{}; + bmm150::BMM150Trim mag_trim_{}; + BMI270AuxDevice aux_device_{BMI270_AUX_DEVICE_NONE}; + uint8_t aux_device_address_{bmm150::BMM150_DEFAULT_I2C_ADDRESS}; + bool magnetometer_ready_{false}; +#endif }; } // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmm150_aux.h b/esphome/components/bmi270/bmm150_aux.h new file mode 100644 index 0000000000..747a0898fa --- /dev/null +++ b/esphome/components/bmi270/bmm150_aux.h @@ -0,0 +1,123 @@ +#pragma once +#include +#include +#include "esphome/core/helpers.h" + +// BMM150 magnetometer definitions - the chip that can optionally be wired to the BMI270's +// auxiliary (secondary I2C master) interface. Lives in its own header/namespace because this +// is BMM150-specific driver knowledge, not BMI270 knowledge - it just can't be a standalone +// ESPHome component, since the BMM150 has no address of its own on the main I2C bus and is only +// reachable by having the BMI270 proxy register reads/writes through its aux interface. +namespace esphome::bmi270::bmm150 { + +static constexpr uint8_t BMM150_DEFAULT_I2C_ADDRESS = 0x10; +static constexpr uint8_t BMM150_REG_CHIP_ID = 0x40; +static constexpr uint8_t BMM150_REG_DATA_X_LSB = 0x42; +static constexpr uint8_t BMM150_REG_POWER_CONTROL = 0x4B; +static constexpr uint8_t BMM150_REG_OP_MODE = 0x4C; +static constexpr uint8_t BMM150_CHIP_ID_VALUE = 0x32; +static constexpr uint8_t BMM150_CMD_POWER_ON = 0x01; // power control bit: suspend -> sleep (3 ms start-up) +static constexpr uint8_t BMM150_CMD_SOFT_RESET = 0x83; // soft reset bits 7 and 1, power control bit kept set +static constexpr uint8_t BMM150_CMD_NORMAL_MODE_ODR_30HZ = 0x38; // data rate 111b (30 Hz), opmode 00b (normal) +static constexpr uint32_t BMM150_START_UP_TIME_MS = 3; +static constexpr uint32_t BMM150_SOFT_RESET_TIME_MS = 1; + +// Factory trim registers 0x5D-0x71, read once at setup and used to compensate every sample. +static constexpr uint8_t BMM150_REG_TRIM_START = 0x5D; +static constexpr uint8_t BMM150_TRIM_BLOCK_LEN = 8; // one aux burst +static constexpr uint8_t BMM150_TRIM_BLOCKS = 3; // 0x5D, 0x65, 0x6D -> covers 0x5D-0x74 +static constexpr uint8_t BMM150_TRIM_X1 = 0x5D; +static constexpr uint8_t BMM150_TRIM_Y1 = 0x5E; +static constexpr uint8_t BMM150_TRIM_Z4 = 0x62; // 16-bit, LSB first +static constexpr uint8_t BMM150_TRIM_X2 = 0x64; +static constexpr uint8_t BMM150_TRIM_Y2 = 0x65; +static constexpr uint8_t BMM150_TRIM_Z2 = 0x68; // 16-bit +static constexpr uint8_t BMM150_TRIM_Z1 = 0x6A; // 16-bit +static constexpr uint8_t BMM150_TRIM_XYZ1 = 0x6C; // 16-bit, 15 valid bits +static constexpr uint8_t BMM150_TRIM_Z3 = 0x6E; // 16-bit +static constexpr uint8_t BMM150_TRIM_XY2 = 0x70; +static constexpr uint8_t BMM150_TRIM_XY1 = 0x71; +static constexpr uint16_t BMM150_TRIM_XYZ1_MASK = 0x7FFF; + +// Raw ADC values that mean "overflow" per the datasheet. +static constexpr int16_t BMM150_XY_OVERFLOW_ADCVAL = -4096; +static constexpr int16_t BMM150_Z_OVERFLOW_ADCVAL = -16384; + +// Result of a BMM150 magnetometer reading, in µT. An axis is NaN when the sensor reported overflow. +struct BMM150Data { + float x; + float y; + float z; +}; + +// Factory trim values (BMM150 datasheet, compensation section). +struct BMM150Trim { + int8_t x1, y1, x2, y2, xy2; + uint8_t xy1; + int16_t z2, z3, z4; + uint16_t z1, xyz1; +}; + +// A little-endian 16-bit trim word, addressed by its register +inline uint16_t bmm150_trim_word(const uint8_t *regs, uint8_t reg) { + const uint8_t offset = reg - BMM150_REG_TRIM_START; + return encode_uint16(regs[offset + 1], regs[offset]); +} + +// Decodes the trim registers; `regs` holds the bytes of registers 0x5D onwards (at least 21). +inline BMM150Trim bmm150_parse_trim(const uint8_t *regs) { + BMM150Trim trim; + trim.x1 = static_cast(regs[BMM150_TRIM_X1 - BMM150_REG_TRIM_START]); + trim.y1 = static_cast(regs[BMM150_TRIM_Y1 - BMM150_REG_TRIM_START]); + trim.z4 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z4)); + trim.x2 = static_cast(regs[BMM150_TRIM_X2 - BMM150_REG_TRIM_START]); + trim.y2 = static_cast(regs[BMM150_TRIM_Y2 - BMM150_REG_TRIM_START]); + trim.z2 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z2)); + trim.z1 = bmm150_trim_word(regs, BMM150_TRIM_Z1); + trim.xyz1 = bmm150_trim_word(regs, BMM150_TRIM_XYZ1) & BMM150_TRIM_XYZ1_MASK; + trim.z3 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z3)); + trim.xy2 = static_cast(regs[BMM150_TRIM_XY2 - BMM150_REG_TRIM_START]); + trim.xy1 = regs[BMM150_TRIM_XY1 - BMM150_REG_TRIM_START]; + return trim; +} + +// Compensates an X or Y sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant). +inline float bmm150_compensate_xy(const BMM150Trim &trim, int8_t trim_a, int8_t trim_b, int16_t raw, uint16_t rhall) { + if (raw == BMM150_XY_OVERFLOW_ADCVAL || rhall == 0 || trim.xyz1 == 0) + return NAN; + float retval = (float) trim.xyz1 * 16384.0f / (float) rhall - 16384.0f; + float comp1 = (float) trim.xy2 * (retval * retval / 268435456.0f); + float comp2 = comp1 + retval * (float) trim.xy1 / 16384.0f; + float comp3 = (float) trim_b + 160.0f; + float comp4 = (float) raw * ((comp2 + 256.0f) * comp3); + return ((comp4 / 8192.0f) + (float) trim_a * 8.0f) / 16.0f; +} + +// Compensates a Z sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant). +inline float bmm150_compensate_z(const BMM150Trim &trim, int16_t raw, uint16_t rhall) { + if (raw == BMM150_Z_OVERFLOW_ADCVAL || trim.z2 == 0 || trim.z1 == 0 || trim.xyz1 == 0 || rhall == 0) + return NAN; + float comp0 = (float) raw - (float) trim.z4; + float comp1 = (float) rhall - (float) trim.xyz1; + float comp2 = (float) trim.z3 * comp1; + float comp3 = (float) trim.z1 * (float) rhall / 32768.0f; + float comp4 = (float) trim.z2 + comp3; + float comp5 = comp0 * 131072.0f - comp2; + return (comp5 / (comp4 * 4.0f)) / 16.0f; +} + +// Converts 8 raw bytes from the BMM150 data registers (X, Y, Z, RHALL; 0x42-0x49) to compensated µT. +inline BMM150Data bmm150_convert(const BMM150Trim &trim, const uint8_t *raw) { + // X/Y are 13-bit (LSB byte bits 7:3), Z is 15-bit (bits 7:1), RHALL is 14-bit (bits 7:2). + int16_t x = (int16_t) (((int16_t) (int8_t) raw[1] * 32) | (raw[0] >> 3)); + int16_t y = (int16_t) (((int16_t) (int8_t) raw[3] * 32) | (raw[2] >> 3)); + int16_t z = (int16_t) (((int16_t) (int8_t) raw[5] * 128) | (raw[4] >> 1)); + uint16_t rhall = (uint16_t) (((uint16_t) raw[7] << 6) | (raw[6] >> 2)); + return BMM150Data{ + .x = bmm150_compensate_xy(trim, trim.x1, trim.x2, x, rhall), + .y = bmm150_compensate_xy(trim, trim.y1, trim.y2, y, rhall), + .z = bmm150_compensate_z(trim, z, rhall), + }; +} + +} // namespace esphome::bmi270::bmm150 diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py index ad36e592d1..97127fd2dd 100644 --- a/esphome/components/bmi270/motion.py +++ b/esphome/components/bmi270/motion.py @@ -14,7 +14,11 @@ from . import BMI270Component, bmi270_ns DEPENDENCIES = ["i2c"] +CONF_AUX_ADDRESS = "aux_address" +CONF_AUX_DEVICE = "aux_device" + # Enum proxies (must match the C++ enum values exactly) +BMI270AuxDevice = bmi270_ns.enum("BMI270AuxDevice") BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange") ACCEL_RANGE_OPTIONS = { "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G, @@ -73,6 +77,13 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum( GYRO_ODR_OPTIONS, upper=True ), + # Optional device wired to the BMI270's aux interface (not the main I2C bus). + # Defaults to NONE so existing configs/boards are unaffected. + cv.Optional(CONF_AUX_DEVICE, default="NONE"): cv.one_of( + "NONE", "BMM150", upper=True + ), + # Default matches the BMM150's fixed I2C address; irrelevant when aux_device is NONE. + cv.Optional(CONF_AUX_ADDRESS, default=0x10): cv.i2c_address, } ) .extend(i2c.i2c_device_schema(0x68)) @@ -90,3 +101,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) + # The aux interface code only compiles when some instance has a BMM150; the + # instance still records its own device, as another one may have none + if config[CONF_AUX_DEVICE] == "BMM150": + cg.add_define("USE_BMI270_BMM150") + cg.add(var.set_aux_device(BMI270AuxDevice.BMI270_AUX_DEVICE_BMM150)) + cg.add(var.set_aux_device_address(config[CONF_AUX_ADDRESS])) diff --git a/esphome/components/bmi270/sensor.py b/esphome/components/bmi270/sensor.py index 0e1b0604a3..242b86614d 100644 --- a/esphome/components/bmi270/sensor.py +++ b/esphome/components/bmi270/sensor.py @@ -6,37 +6,97 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, DEVICE_CLASS_TEMPERATURE, + ICON_MAGNET, ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, + UNIT_MICROTESLA, ) from esphome.cpp_generator import MockObj +import esphome.final_validate as fv from esphome.types import ConfigType -from . import CONF_BMI270_ID, BMI270Component +from . import CONF_BMI270_ID, BMI270Component, bmi270_ns +from .motion import CONF_AUX_DEVICE AUTO_LOAD = ["bmi270"] -CONFIG_SCHEMA = sensor.sensor_schema( - unit_of_measurement=UNIT_CELSIUS, - icon=ICON_THERMOMETER, - accuracy_decimals=2, - state_class=STATE_CLASS_MEASUREMENT, - device_class=DEVICE_CLASS_TEMPERATURE, -).extend( +CONF_MAGNETIC_FIELD_X = "magnetic_field_x" +CONF_MAGNETIC_FIELD_Y = "magnetic_field_y" +CONF_MAGNETIC_FIELD_Z = "magnetic_field_z" + +BMM150Data = bmi270_ns.namespace("bmm150").class_("BMM150Data") + +_MAGNETIC_FIELDS = (CONF_MAGNETIC_FIELD_X, CONF_MAGNETIC_FIELD_Y, CONF_MAGNETIC_FIELD_Z) + + +def _parent_schema() -> cv.Schema: + return cv.Schema({cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component)}) + + +def _temperature_sensor_schema() -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, + ).extend(_parent_schema()) + + +def _magnetic_field_sensor_schema() -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_MICROTESLA, + icon=ICON_MAGNET, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(_parent_schema()) + + +# `type` defaults to temperature so existing configs that omit it keep working unchanged. +CONFIG_SCHEMA = cv.typed_schema( { - cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), - cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component), - } + CONF_TEMPERATURE: _temperature_sensor_schema(), + **{x: _magnetic_field_sensor_schema() for x in _MAGNETIC_FIELDS}, + }, + default_type=CONF_TEMPERATURE, ) +def _final_validate(config: ConfigType) -> None: + if config[CONF_TYPE] not in _MAGNETIC_FIELDS: + return + full_config = fv.full_config.get() + bmi270_path = full_config.get_path_for_id(config[CONF_BMI270_ID])[:-1] + bmi270_config = full_config.get_config_for_path(bmi270_path) + if bmi270_config.get(CONF_AUX_DEVICE) != "BMM150": + raise cv.Invalid( + "This sensor requires the parent bmi270's 'aux_device' to be set to 'BMM150'", + path=[CONF_TYPE], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: + sensor_type = config[CONF_TYPE] var = await sensor.new_sensor(config) parent = await cg.get_variable(config[CONF_BMI270_ID]) + + if sensor_type == CONF_TEMPERATURE: + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) + return + + axis = sensor_type[-1:] data = MockObj("data") value_lambda = await cg.process_lambda( - var.publish_state(data), - [(cg.float_, str(data))], + var.publish_state(getattr(data, axis)), + [(BMM150Data.operator("const").operator("ref"), str(data))], ) - cg.add(parent.add_temperature_listener(value_lambda)) + cg.add(parent.add_magnetometer_listener(value_lambda)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a1b32b39f5..c645428c7c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -48,6 +48,7 @@ #define USE_BINARY_SENSOR_FILTER #define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK +#define USE_BMI270_BMM150 #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE diff --git a/tests/components/bmi270/common-plain.yaml b/tests/components/bmi270/common-plain.yaml new file mode 100644 index 0000000000..13091521a8 --- /dev/null +++ b/tests/components/bmi270/common-plain.yaml @@ -0,0 +1,11 @@ +motion: + - platform: bmi270 + id: plain_imu + i2c_id: i2c_bus + address: 0x69 + +sensor: + - platform: bmi270 + bmi270_id: plain_imu + type: temperature + name: Plain IMU Temperature diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 41f30fd5c6..390def04a1 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -1,7 +1,21 @@ sensor: - platform: bmi270 + bmi270_id: bmi270_motion name: "BMI270 Temperature" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_x + name: "BMM150 Magnetic Field X" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_y + name: "BMM150 Magnetic Field Y" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_z + name: "BMM150 Magnetic Field Z" + - platform: motion motion_id: bmi270_motion type: acceleration_x @@ -75,6 +89,8 @@ motion: # Gyroscope output data rate: 25HZ | 50HZ | 100HZ | 200HZ | # 400HZ | 800HZ | 1600HZ | 3200HZ gyroscope_odr: 200HZ + aux_device: BMM150 + aux_address: 0x10 axis_map: x: y y: x diff --git a/tests/components/bmi270/test-plain.esp32-idf.yaml b/tests/components/bmi270/test-plain.esp32-idf.yaml new file mode 100644 index 0000000000..aafcb7af3a --- /dev/null +++ b/tests/components/bmi270/test-plain.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bmi270: !include common-plain.yaml diff --git a/tests/components/bmi270/test.esp32-idf.yaml b/tests/components/bmi270/test.esp32-idf.yaml index b47e39c389..63ea3c5a1a 100644 --- a/tests/components/bmi270/test.esp32-idf.yaml +++ b/tests/components/bmi270/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + bmi270: !include common.yaml + bmi270_plain: !include common-plain.yaml From 73d5bb3c97534aec5105f3e7e9d546ae43fd0c40 Mon Sep 17 00:00:00 2001 From: naKruul <40398952+naKruul@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:09:40 +0200 Subject: [PATCH 228/268] [remko_ar715] Add Remko AR-715 IR climate platform (#17173) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/remko_ar715/__init__.py | 2 + esphome/components/remko_ar715/climate.py | 22 ++++ .../components/remko_ar715/remko_ar715.cpp | 93 +++++++++++++++ esphome/components/remko_ar715/remko_ar715.h | 37 ++++++ tests/components/remko_ar715/__init__.py | 7 ++ tests/components/remko_ar715/common.yaml | 3 + .../remko_ar715/remko_ar715_tests.cpp | 106 ++++++++++++++++++ .../remko_ar715/test.esp32-idf.yaml | 3 + .../remko_ar715/test.esp8266-ard.yaml | 3 + 10 files changed, 277 insertions(+) create mode 100644 esphome/components/remko_ar715/__init__.py create mode 100644 esphome/components/remko_ar715/climate.py create mode 100644 esphome/components/remko_ar715/remko_ar715.cpp create mode 100644 esphome/components/remko_ar715/remko_ar715.h create mode 100644 tests/components/remko_ar715/__init__.py create mode 100644 tests/components/remko_ar715/common.yaml create mode 100644 tests/components/remko_ar715/remko_ar715_tests.cpp create mode 100644 tests/components/remko_ar715/test.esp32-idf.yaml create mode 100644 tests/components/remko_ar715/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c74ae5616b..5e88b7d7e7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -472,6 +472,7 @@ esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet esphome/components/rd03d/* @jasstrong +esphome/components/remko_ar715/* @naKruul esphome/components/renogy_inverter_ble/* @emilioaray-dev esphome/components/resampler/speaker/* @kahrendt esphome/components/restart/* @esphome/core diff --git a/esphome/components/remko_ar715/__init__.py b/esphome/components/remko_ar715/__init__.py new file mode 100644 index 0000000000..18eb60df33 --- /dev/null +++ b/esphome/components/remko_ar715/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@naKruul"] +DOMAIN = "remko_ar715" diff --git a/esphome/components/remko_ar715/climate.py b/esphome/components/remko_ar715/climate.py new file mode 100644 index 0000000000..d640993a4c --- /dev/null +++ b/esphome/components/remko_ar715/climate.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL +import esphome.config_validation as cv +from esphome.const import CONF_SUPPORTS_HEAT +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +remko_ar715_ns = cg.esphome_ns.namespace("remko_ar715") +RemkoAr715Climate = remko_ar715_ns.class_("RemkoAr715Climate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_schema(RemkoAr715Climate).extend( + { + cv.Optional(CONF_SUPPORTS_HEAT, default=False): cv.boolean_false, + cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false, + } +) + + +async def to_code(config: ConfigType) -> None: + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/remko_ar715/remko_ar715.cpp b/esphome/components/remko_ar715/remko_ar715.cpp new file mode 100644 index 0000000000..f6d5a2f62c --- /dev/null +++ b/esphome/components/remko_ar715/remko_ar715.cpp @@ -0,0 +1,93 @@ +#include "remko_ar715.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include + +namespace esphome::remko_ar715 { + +ESPHOME_LOG_TAG(TAG, "remko_ar715.climate"); + +// Timings from ir_ClimaButler.cpp (IRremoteESP8266), in microseconds +static constexpr uint16_t BIT_MARK = 511; +static constexpr uint16_t HDR_SPACE = 3492; +static constexpr uint16_t ONE_SPACE = 1540; +static constexpr uint16_t ZERO_SPACE = 548; +static constexpr uint32_t CARRIER_FREQUENCY = 38000; +static constexpr uint8_t BITS = 52; + +static constexpr uint8_t POWER_ON = 0x3; +static constexpr uint8_t POWER_OFF = 0x0; +static constexpr uint8_t SWING_ON = 0x5; +static constexpr uint8_t SWING_OFF = 0x7; + +// Mode/fan byte: high nibble N3, low nibble N2 +static constexpr uint8_t MODE_FAN_AUTO = 0x80; +static constexpr uint8_t MODE_FAN_LOW = 0xA2; +static constexpr uint8_t MODE_FAN_MEDIUM = 0x91; +static constexpr uint8_t MODE_FAN_HIGH = 0x88; +static constexpr uint8_t MODE_DRY = 0xA4; +static constexpr uint8_t MODE_FAN_ONLY = 0xA3; + +static uint8_t fan_mode_byte(climate::ClimateFanMode fan_mode) { + switch (fan_mode) { + case climate::CLIMATE_FAN_HIGH: + return MODE_FAN_HIGH; + case climate::CLIMATE_FAN_MEDIUM: + return MODE_FAN_MEDIUM; + case climate::CLIMATE_FAN_LOW: + return MODE_FAN_LOW; + default: + return MODE_FAN_AUTO; + } +} + +// 52-bit word, 13 nibbles MSB first: +// N12=0x8 (fixed), N11=power, N10..N5=0 (timer, unused), N4=swing, +// N3,N2=mode/fan, N1=temp-16, N0=checksum (0xF - sum(N12..N1)) & 0xF +static uint64_t build_frame(uint8_t power, uint8_t swing, uint8_t mode_fan, uint8_t temp) { + const uint8_t temp_nibble = (temp - 16) & 0xF; + const uint8_t sum = 0x8 + power + swing + (mode_fan >> 4) + (mode_fan & 0xF) + temp_nibble; + const uint8_t checksum = (0xF - sum) & 0xF; + return (uint64_t{0x8} << 48) | (uint64_t{power} << 44) | (uint32_t{swing} << 16) | (uint32_t{mode_fan} << 8) | + (temp_nibble << 4) | checksum; +} + +static void send_frame(remote_base::RemoteTransmitData *data, uint64_t code) { + data->set_carrier_frequency(CARRIER_FREQUENCY); + data->reserve(2 + BITS * 2 + 2); + data->item(BIT_MARK, HDR_SPACE); + for (int i = BITS - 1; i >= 0; i--) + data->item(BIT_MARK, (code >> i) & 1 ? ONE_SPACE : ZERO_SPACE); + // Stop bit and inter-frame gap + data->item(BIT_MARK, HDR_SPACE); +} + +void RemkoAr715Climate::transmit_state() { + const uint8_t temp = lroundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX)); + const bool power = this->mode != climate::CLIMATE_MODE_OFF; + const bool swing = this->swing_mode == climate::CLIMATE_SWING_VERTICAL; + + // DRY and FAN_ONLY carry no fan speed: mode and fan share the same nibbles + uint8_t mode_fan; + switch (this->mode) { + case climate::CLIMATE_MODE_DRY: + mode_fan = MODE_DRY; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + mode_fan = MODE_FAN_ONLY; + break; + default: + mode_fan = fan_mode_byte(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + break; + } + + const uint64_t code = build_frame(power ? POWER_ON : POWER_OFF, swing ? SWING_ON : SWING_OFF, mode_fan, temp); + ESP_LOGV(TAG, "Sending power=%s temp=%u swing=%s mode_fan=0x%02X code=0x%05" PRIX32 "%08" PRIX32, ONOFF(power), temp, + ONOFF(swing), mode_fan, static_cast(code >> 32), static_cast(code)); + + auto transmit = this->transmitter_->transmit(); + send_frame(transmit.get_data(), code); + transmit.perform(); +} + +} // namespace esphome::remko_ar715 diff --git a/esphome/components/remko_ar715/remko_ar715.h b/esphome/components/remko_ar715/remko_ar715.h new file mode 100644 index 0000000000..463617bbc6 --- /dev/null +++ b/esphome/components/remko_ar715/remko_ar715.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::remko_ar715 { + +// Remko AR-715 IR remote: 52-bit pulse-distance frame, full state in every command. +// IRremoteESP8266 calls it the ClimaButler protocol after a rebrand; Remko is the manufacturer. +// Protocol analysis: https://github.com/crankyoldgit/IRremoteESP8266/issues/1812 +// +// Supported devices: +// - Remko RKL series (RKL 490, 491, 494, 495) with AR-715 remote +// - Fischer ClimaButler RCS-SD43UAI / RCS-SD43UWI +// - TROTEC PAC 4600 +// - Novamatic CL 990 / CL 1590 +// - Rexair C15000N +// - freecom RCS-SD43UAI / RCS-SD43UWI + +static constexpr float TEMP_MIN = 16.0f; +static constexpr float TEMP_MAX = 30.0f; + +class RemkoAr715Climate final : public climate_ir::ClimateIR { + public: + RemkoAr715Climate() + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, + 1.0f, // temperature step + true, // supports dry + true, // supports fan only + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + + protected: + void transmit_state() override; +}; + +} // namespace esphome::remko_ar715 diff --git a/tests/components/remko_ar715/__init__.py b/tests/components/remko_ar715/__init__.py new file mode 100644 index 0000000000..ec1dafdf15 --- /dev/null +++ b/tests/components/remko_ar715/__init__.py @@ -0,0 +1,7 @@ +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. + manifest.auto_load = ["climate_ir", "climate"] diff --git a/tests/components/remko_ar715/common.yaml b/tests/components/remko_ar715/common.yaml new file mode 100644 index 0000000000..b284bc20c2 --- /dev/null +++ b/tests/components/remko_ar715/common.yaml @@ -0,0 +1,3 @@ +climate: + - platform: remko_ar715 + name: Test RemkoAr715 diff --git a/tests/components/remko_ar715/remko_ar715_tests.cpp b/tests/components/remko_ar715/remko_ar715_tests.cpp new file mode 100644 index 0000000000..bb1cece60f --- /dev/null +++ b/tests/components/remko_ar715/remko_ar715_tests.cpp @@ -0,0 +1,106 @@ +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/remko_ar715/remko_ar715.h" + +namespace esphome::remko_ar715::testing { + +static constexpr int32_t BIT_MARK = 511; +static constexpr int32_t HDR_SPACE = 3492; +static constexpr int32_t ONE_SPACE = 1540; +static constexpr int32_t ZERO_SPACE = 548; +static constexpr int BITS = 52; + +/// The waveform the component must produce for a code: header, 52 bits MSB first, stop bit +static remote_base::RawTimings waveform(uint64_t code) { + remote_base::RawTimings raw; + raw.push_back(BIT_MARK); + raw.push_back(-HDR_SPACE); + for (int i = BITS - 1; i >= 0; i--) { + raw.push_back(BIT_MARK); + raw.push_back((code >> i) & 1 ? -ONE_SPACE : -ZERO_SPACE); + } + raw.push_back(BIT_MARK); + raw.push_back(-HDR_SPACE); + return raw; +} + +/// Decodes a frame back into its code so a mismatch is readable +static uint64_t code_of(const remote_base::RawTimings &raw) { + uint64_t code = 0; + for (int bit = 0; bit < BITS; bit++) { + const size_t space = 3 + 2 * bit; + code = (code << 1) | (space < raw.size() && -raw[space] > 1000 ? 1 : 0); + } + return code; +} + +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; + RemkoAr715Climate climate; + + Fixture() { + this->climate.set_transmitter(&this->transmitter); + this->climate.setup(); + } + + void command(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(); + } + + const remote_base::RawTimings &last_frame() const { return this->transmitter.frames.back(); } +}; + +static void expect_code(const remote_base::RawTimings &actual, uint64_t code) { + EXPECT_EQ(actual, waveform(code)) << std::hex << "actual 0x" << code_of(actual) << "\nexpected 0x" << code; +} + +// Codes captured from the AR-715 remote in IRremoteESP8266 issue 1812 +TEST(RemkoAr715, EncodesCapturedFrames) { + Fixture f; + + f.command(climate::CLIMATE_MODE_COOL, 16, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + expect_code(f.last_frame(), 0x830000005880FULL); + + // Power off keeps the rest of the state in the frame + f.command(climate::CLIMATE_MODE_OFF, 16, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + expect_code(f.last_frame(), 0x8000000058802ULL); + + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007919AULL); + + f.command(climate::CLIMATE_MODE_COOL, 20, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007914FULL); +} + +// Dry and fan-only replace the fan nibbles; the fan speed is not sent +TEST(RemkoAr715, DryAndFanOnlyIgnoreTheFanSpeed) { + Fixture f; + + f.command(climate::CLIMATE_MODE_DRY, 22, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007A469ULL); + + f.command(climate::CLIMATE_MODE_FAN_ONLY, 22, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007A36AULL); +} + +} // namespace esphome::remko_ar715::testing diff --git a/tests/components/remko_ar715/test.esp32-idf.yaml b/tests/components/remko_ar715/test.esp32-idf.yaml new file mode 100644 index 0000000000..128f6d444a --- /dev/null +++ b/tests/components/remko_ar715/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remko_ar715: !include common.yaml diff --git a/tests/components/remko_ar715/test.esp8266-ard.yaml b/tests/components/remko_ar715/test.esp8266-ard.yaml new file mode 100644 index 0000000000..bc4c8ddda1 --- /dev/null +++ b/tests/components/remko_ar715/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remko_ar715: !include common.yaml From 0c43d689d7852d4a75e471d932014ba351ecc92c Mon Sep 17 00:00:00 2001 From: pl4nkton Date: Tue, 13 Oct 2026 01:11:51 +0200 Subject: [PATCH 229/268] [sh1122] Add OLED display driver (#15295) Co-authored-by: Nicolas Reinecke Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + esphome/components/sh1122_base/__init__.py | 43 ++++++ .../components/sh1122_base/sh1122_base.cpp | 146 ++++++++++++++++++ esphome/components/sh1122_base/sh1122_base.h | 59 +++++++ esphome/components/sh1122_spi/__init__.py | 1 + esphome/components/sh1122_spi/display.py | 37 +++++ esphome/components/sh1122_spi/sh1122_spi.cpp | 42 +++++ esphome/components/sh1122_spi/sh1122_spi.h | 25 +++ tests/components/sh1122_spi/common.yaml | 19 +++ .../components/sh1122_spi/test.esp32-idf.yaml | 8 + .../sh1122_spi/test.esp8266-ard.yaml | 8 + .../sh1122_spi/test.rp2040-ard.yaml | 8 + 12 files changed, 398 insertions(+) create mode 100644 esphome/components/sh1122_base/__init__.py create mode 100644 esphome/components/sh1122_base/sh1122_base.cpp create mode 100644 esphome/components/sh1122_base/sh1122_base.h create mode 100644 esphome/components/sh1122_spi/__init__.py create mode 100644 esphome/components/sh1122_spi/display.py create mode 100644 esphome/components/sh1122_spi/sh1122_spi.cpp create mode 100644 esphome/components/sh1122_spi/sh1122_spi.h create mode 100644 tests/components/sh1122_spi/common.yaml create mode 100644 tests/components/sh1122_spi/test.esp32-idf.yaml create mode 100644 tests/components/sh1122_spi/test.esp8266-ard.yaml create mode 100644 tests/components/sh1122_spi/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 5e88b7d7e7..b25611b3b6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -522,6 +522,8 @@ esphome/components/sfa30/* @ghsensdev esphome/components/sfa40/* @NoQuarrel esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw +esphome/components/sh1122_base/* @pl4nkton +esphome/components/sh1122_spi/* @pl4nkton esphome/components/sha256/* @esphome/core esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py new file mode 100644 index 0000000000..4f2b026a5e --- /dev/null +++ b/esphome/components/sh1122_base/__init__.py @@ -0,0 +1,43 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@pl4nkton"] +DOMAIN = "sh1122_base" + +sh1122_base_ns = cg.esphome_ns.namespace("sh1122_base") +SH1122 = sh1122_base_ns.class_("SH1122", cg.PollingComponent, display.DisplayBuffer) +SH1122Model = sh1122_base_ns.enum("SH1122Model") + +MODELS = { + "SH1122_256X64": SH1122Model.SH1122_MODEL_256_64, +} + +SH1122_MODEL = cv.enum(MODELS, upper=True, space="_") + +SH1122_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.Optional(CONF_MODEL, default="SH1122_256X64"): SH1122_MODEL, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage, + } +).extend(cv.polling_component_schema("1s")) + + +async def setup_sh1122(var: MockObj, config: ConfigType) -> None: + await display.register_display(var, config) + + cg.add(var.set_model(config[CONF_MODEL])) + if (reset_pin := config.get(CONF_RESET_PIN)) is not None: + reset = await cg.gpio_pin_expression(reset_pin) + cg.add(var.set_reset_pin(reset)) + cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/sh1122_base/sh1122_base.cpp b/esphome/components/sh1122_base/sh1122_base.cpp new file mode 100644 index 0000000000..72d5e44235 --- /dev/null +++ b/esphome/components/sh1122_base/sh1122_base.cpp @@ -0,0 +1,146 @@ +#include "sh1122_base.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::sh1122_base { + +ESPHOME_LOG_TAG(TAG, "sh1122"); + +static constexpr uint8_t SH1122_MAX_CONTRAST = 255; +static constexpr uint8_t SH1122_COLORMASK = 0x0f; +static constexpr uint8_t SH1122_COLORSHIFT = 4; +static constexpr uint8_t SH1122_PIXELSPERBYTE = 2; + +static constexpr uint8_t SH1122_SETLOWCOLUMNADDRESS = 0x00; +static constexpr uint8_t SH1122_SETHIGHCOLUMNADDRESS = 0x10; +static constexpr uint8_t SH1122_SETDISCHARGEVSLLEVEL = 0x30; +static constexpr uint8_t SH1122_SETDISPLAYSTARTLINE = 0x40; +static constexpr uint8_t SH1122_SETCONTRASTCURRENT = 0x81; +static constexpr uint8_t SH1122_SETSEGMENTREMAP = 0xA0; +static constexpr uint8_t SH1122_SETDISPLAYOFFON = 0xA4; +static constexpr uint8_t SH1122_SETNORMALDISPLAY = 0xA6; +static constexpr uint8_t SH1122_SETMULTIPLEXRATIO = 0xA8; +static constexpr uint8_t SH1122_DCDCSETTING = 0xAD; +static constexpr uint8_t SH1122_SETDISPLAYOFF = 0xAE; +static constexpr uint8_t SH1122_SETDISPLAYON = 0xAF; +static constexpr uint8_t SH1122_SETROWADDRESS = 0xB0; +static constexpr uint8_t SH1122_SETSCANDIRECTION = 0xC0; +static constexpr uint8_t SH1122_SETDISPLAYOFFSET = 0xD3; +static constexpr uint8_t SH1122_SETCLOCKDIVIDER = 0xD5; +static constexpr uint8_t SH1122_SETDISCHARGEPRECHARGEPERIOD = 0xD9; +static constexpr uint8_t SH1122_SETVCOMDESELECTLEVEL = 0xDB; +static constexpr uint8_t SH1122_SETVSEGMLEVEL = 0xDC; + +void SH1122::setup() { + this->init_internal_(this->get_buffer_length_()); + if (this->buffer_ == nullptr) { + this->mark_failed(); + return; + } + + this->turn_off(); + this->command2_(SH1122_SETCLOCKDIVIDER, 0x50); + this->command2_(SH1122_SETMULTIPLEXRATIO, 0x3F); + this->command2_(SH1122_SETDISPLAYOFFSET, 0x00); + this->command2_(SH1122_SETROWADDRESS, 0x00); + this->command_(SH1122_SETDISPLAYSTARTLINE | 32); // the panel's rows start half way down the 128 line RAM + this->command_(SH1122_SETDISCHARGEVSLLEVEL); + this->command2_(SH1122_DCDCSETTING, 0x81); + this->command_(SH1122_SETSEGMENTREMAP | 0x01); // column 0 on the right + this->command_(SH1122_SETSCANDIRECTION | 0x08); // rows scanned bottom up + this->command2_(SH1122_SETDISCHARGEPRECHARGEPERIOD, 0x28); + this->command2_(SH1122_SETVCOMDESELECTLEVEL, 0x35); + this->command2_(SH1122_SETVSEGMLEVEL, 0x35); + this->command_(SH1122_SETNORMALDISPLAY); + this->command_(SH1122_SETHIGHCOLUMNADDRESS); + this->command_(SH1122_SETLOWCOLUMNADDRESS); + this->command_(SH1122_SETDISPLAYOFFON); + this->set_brightness(this->brightness_); + this->display(); // the buffer starts cleared; push it so no power-on garbage shows + this->turn_on(); +} +void SH1122::display() { + // Return the RAM pointer to the top left corner in one transfer, then stream the frame + const uint8_t set_address[] = {SH1122_SETHIGHCOLUMNADDRESS, SH1122_SETLOWCOLUMNADDRESS, SH1122_SETROWADDRESS, 0x00}; + this->write_command(set_address, sizeof(set_address)); + this->write_display_data(); +} +void SH1122::update() { + this->do_update_(); + this->display(); +} +void SH1122::set_brightness(float brightness) { + this->brightness_ = clamp(brightness, 0.0F, 1.0F); + this->command2_(SH1122_SETCONTRASTCURRENT, int(SH1122_MAX_CONTRAST * (this->brightness_))); +} +void SH1122::turn_on() { + this->command_(SH1122_SETDISPLAYON); + this->is_on_ = true; +} +void SH1122::turn_off() { + this->command_(SH1122_SETDISPLAYOFF); + this->is_on_ = false; +} +int SH1122::get_height_internal() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return 64; + default: + return 0; + } +} +int SH1122::get_width_internal() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return 256; + default: + return 0; + } +} +size_t SH1122::get_buffer_length_() { + return size_t(this->get_width_internal()) * size_t(this->get_height_internal()) / SH1122_PIXELSPERBYTE; +} +void HOT SH1122::draw_absolute_pixel_internal(int x, int y, Color color) { + const int width = this->get_width_internal(); + if (x >= width || x < 0 || y >= this->get_height_internal() || y < 0) + return; + // Two pixels per byte, the left one in the high nibble + const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color) & SH1122_COLORMASK; + const uint16_t pos = (x / SH1122_PIXELSPERBYTE) + (y * width / SH1122_PIXELSPERBYTE); + const uint8_t shift = (1u - (x % SH1122_PIXELSPERBYTE)) * SH1122_COLORSHIFT; + this->buffer_[pos] = (this->buffer_[pos] & (static_cast(~SH1122_COLORMASK) >> shift)) | (color4 << shift); +} +void SH1122::fill(Color color) { + // If clipping is active, fall back to base implementation + if (this->get_clipping().is_set()) { + Display::fill(color); + return; + } + const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color); + const uint8_t fill = (color4 & SH1122_COLORMASK) | ((color4 & SH1122_COLORMASK) << SH1122_COLORSHIFT); + memset(this->buffer_, fill, this->get_buffer_length_()); +} +void SH1122::init_reset_() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(1); + // Trigger Reset + this->reset_pin_->digital_write(false); + delay(10); + // Wake up + this->reset_pin_->digital_write(true); + } +} +const LogString *SH1122::model_str_() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return LOG_STR("SH1122 256x64"); + default: + return LOG_STR("Unknown"); + } +} + +} // namespace esphome::sh1122_base diff --git a/esphome/components/sh1122_base/sh1122_base.h b/esphome/components/sh1122_base/sh1122_base.h new file mode 100644 index 0000000000..c3e4719d39 --- /dev/null +++ b/esphome/components/sh1122_base/sh1122_base.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/components/display/display_buffer.h" + +namespace esphome::sh1122_base { + +enum SH1122Model : uint8_t { + SH1122_MODEL_256_64 = 0, +}; + +class SH1122 : public display::DisplayBuffer { + public: + void setup() override; + + void display(); + + void update() override; + + void set_model(SH1122Model model) { this->model_ = model; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + void init_brightness(float brightness) { this->brightness_ = brightness; } + void set_brightness(float brightness); + bool is_on() { return this->is_on_; } + void turn_on(); + void turn_off(); + + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + void fill(Color color) override; + + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_GRAYSCALE; } + + protected: + /// Sends a command byte and its arguments; the SH1122 takes command arguments with D/C low as well + virtual void write_command(const uint8_t *bytes, size_t len) = 0; + virtual void write_display_data() = 0; + void command_(uint8_t value) { this->write_command(&value, 1); } + void command2_(uint8_t value, uint8_t data) { + const uint8_t bytes[2] = {value, data}; + this->write_command(bytes, 2); + } + void init_reset_(); + + void draw_absolute_pixel_internal(int x, int y, Color color) override; + + int get_height_internal() override; + int get_width_internal() override; + size_t get_buffer_length_(); + const LogString *model_str_(); + + GPIOPin *reset_pin_{nullptr}; + float brightness_{1.0}; + SH1122Model model_{SH1122_MODEL_256_64}; + bool is_on_{false}; +}; + +} // namespace esphome::sh1122_base diff --git a/esphome/components/sh1122_spi/__init__.py b/esphome/components/sh1122_spi/__init__.py new file mode 100644 index 0000000000..4ed7022267 --- /dev/null +++ b/esphome/components/sh1122_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sh1122_spi" diff --git a/esphome/components/sh1122_spi/display.py b/esphome/components/sh1122_spi/display.py new file mode 100644 index 0000000000..36acb9fd59 --- /dev/null +++ b/esphome/components/sh1122_spi/display.py @@ -0,0 +1,37 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import sh1122_base, spi +import esphome.config_validation as cv +from esphome.const import CONF_DC_PIN, CONF_ID, CONF_LAMBDA, CONF_PAGES +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@pl4nkton"] + +AUTO_LOAD = ["sh1122_base"] +DEPENDENCIES = ["spi"] + +sh1122_spi = cg.esphome_ns.namespace("sh1122_spi") +SPISH1122 = sh1122_spi.class_("SPISH1122", sh1122_base.SH1122, spi.SPIDevice) + +CONFIG_SCHEMA = cv.All( + sh1122_base.SH1122_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SPISH1122), + cv.Required(CONF_DC_PIN): pins.gpio_output_pin_schema, + } + ).extend(spi.spi_device_schema(cs_pin_required=False)), + cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA), +) + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + DOMAIN, require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) + var = cg.new_Pvariable(config[CONF_ID], dc) + await sh1122_base.setup_sh1122(var, config) + await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/sh1122_spi/sh1122_spi.cpp b/esphome/components/sh1122_spi/sh1122_spi.cpp new file mode 100644 index 0000000000..2e925aea29 --- /dev/null +++ b/esphome/components/sh1122_spi/sh1122_spi.cpp @@ -0,0 +1,42 @@ +#include "sh1122_spi.h" +#include "esphome/core/log.h" + +namespace esphome::sh1122_spi { + +ESPHOME_LOG_TAG(TAG, "sh1122_spi"); + +void SPISH1122::setup() { + this->spi_setup(); + this->dc_pin_->setup(); + + this->init_reset_(); + delay(500); // NOLINT + SH1122::setup(); +} +void SPISH1122::dump_config() { + LOG_DISPLAY("", "SPI SH1122", this); + ESP_LOGCONFIG(TAG, + " Model: %s\n" + " Initial Brightness: %.2f", + LOG_STR_ARG(this->model_str_()), this->brightness_); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" DC Pin: ", this->dc_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_UPDATE_INTERVAL(this); +} +// enable() and disable() drive the CS pin. The D/C setup time is a few tens of nanoseconds, so the +// GPIO write followed by the CS edge already satisfies it; no delay is needed. +void SPISH1122::write_command(const uint8_t *bytes, size_t len) { + this->dc_pin_->digital_write(false); + this->enable(); + this->write_array(bytes, len); + this->disable(); +} +void HOT SPISH1122::write_display_data() { + this->dc_pin_->digital_write(true); + this->enable(); + this->write_array(this->buffer_, this->get_buffer_length_()); + this->disable(); +} + +} // namespace esphome::sh1122_spi diff --git a/esphome/components/sh1122_spi/sh1122_spi.h b/esphome/components/sh1122_spi/sh1122_spi.h new file mode 100644 index 0000000000..f5da209fe2 --- /dev/null +++ b/esphome/components/sh1122_spi/sh1122_spi.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/sh1122_base/sh1122_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::sh1122_spi { + +class SPISH1122 final : public sh1122_base::SH1122, + public spi::SPIDevice { + public: + explicit SPISH1122(GPIOPin *dc_pin) : dc_pin_(dc_pin) {} + + void setup() override; + + void dump_config() override; + + protected: + void write_command(const uint8_t *bytes, size_t len) override; + void write_display_data() override; + + GPIOPin *dc_pin_; +}; + +} // namespace esphome::sh1122_spi diff --git a/tests/components/sh1122_spi/common.yaml b/tests/components/sh1122_spi/common.yaml new file mode 100644 index 0000000000..a48a1a5711 --- /dev/null +++ b/tests/components/sh1122_spi/common.yaml @@ -0,0 +1,19 @@ +display: + - platform: sh1122_spi + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + reset_pin: ${reset_pin} + brightness: 80% + pages: + - id: sh1122_spi_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: sh1122_spi_page2 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + on_page_change: + from: sh1122_spi_page1 + to: sh1122_spi_page2 + then: + lambda: |- + ESP_LOGD("display", "1 -> 2"); diff --git a/tests/components/sh1122_spi/test.esp32-idf.yaml b/tests/components/sh1122_spi/test.esp32-idf.yaml new file mode 100644 index 0000000000..10efaac7f4 --- /dev/null +++ b/tests/components/sh1122_spi/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO12 + dc_pin: GPIO13 + reset_pin: GPIO14 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sh1122_spi: !include common.yaml diff --git a/tests/components/sh1122_spi/test.esp8266-ard.yaml b/tests/components/sh1122_spi/test.esp8266-ard.yaml new file mode 100644 index 0000000000..bc1105479d --- /dev/null +++ b/tests/components/sh1122_spi/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO5 + dc_pin: GPIO15 + reset_pin: GPIO16 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + sh1122_spi: !include common.yaml diff --git a/tests/components/sh1122_spi/test.rp2040-ard.yaml b/tests/components/sh1122_spi/test.rp2040-ard.yaml new file mode 100644 index 0000000000..91ec731ca9 --- /dev/null +++ b/tests/components/sh1122_spi/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO20 + dc_pin: GPIO21 + reset_pin: GPIO22 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + sh1122_spi: !include common.yaml From 132e9dac8b52ae13aeeab07b205e2e026fbf00f2 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 13 Oct 2026 01:13:11 +0200 Subject: [PATCH 230/268] [tmc2209] Add TMC2209 stepper driver (#20437) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + esphome/components/tmc2209/__init__.py | 10 + esphome/components/tmc2209/stepper.py | 14 + esphome/components/tmc2209/tmc2209.cpp | 13 + esphome/components/tmc2209/tmc2209.h | 15 + esphome/components/tmc22xx/__init__.py | 211 ++++++++++ esphome/components/tmc22xx/tmc22xx.cpp | 372 ++++++++++++++++++ esphome/components/tmc22xx/tmc22xx.h | 167 ++++++++ tests/components/tmc2209/common.yaml | 57 +++ tests/components/tmc2209/test.esp32-idf.yaml | 9 + .../components/tmc2209/test.esp8266-ard.yaml | 9 + tests/components/tmc2209/test.rp2040-ard.yaml | 9 + 12 files changed, 888 insertions(+) create mode 100644 esphome/components/tmc2209/__init__.py create mode 100644 esphome/components/tmc2209/stepper.py create mode 100644 esphome/components/tmc2209/tmc2209.cpp create mode 100644 esphome/components/tmc2209/tmc2209.h create mode 100644 esphome/components/tmc22xx/__init__.py create mode 100644 esphome/components/tmc22xx/tmc22xx.cpp create mode 100644 esphome/components/tmc22xx/tmc22xx.h create mode 100644 tests/components/tmc2209/common.yaml create mode 100644 tests/components/tmc2209/test.esp32-idf.yaml create mode 100644 tests/components/tmc2209/test.esp8266-ard.yaml create mode 100644 tests/components/tmc2209/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b25611b3b6..2c637b0d20 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -617,6 +617,8 @@ esphome/components/tm1621/* @Philippe12 esphome/components/tm1637/* @glmnet esphome/components/tm1638/* @skykingjwc esphome/components/tm1651/* @mrtoy-me +esphome/components/tmc2209/* @remcom +esphome/components/tmc22xx/* @remcom esphome/components/tmp102/* @timsavage esphome/components/tmp1075/* @sybrenstuvel esphome/components/tmp117/* @Azimath diff --git a/esphome/components/tmc2209/__init__.py b/esphome/components/tmc2209/__init__.py new file mode 100644 index 0000000000..b3822f7582 --- /dev/null +++ b/esphome/components/tmc2209/__init__.py @@ -0,0 +1,10 @@ +import esphome.codegen as cg +from esphome.components import tmc22xx + +CODEOWNERS = ["@remcom"] +DOMAIN = "tmc2209" + +tmc2209_ns = cg.esphome_ns.namespace("tmc2209") +TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper) + +tmc22xx.register_actions("tmc2209", TMC2209Stepper) diff --git a/esphome/components/tmc2209/stepper.py b/esphome/components/tmc2209/stepper.py new file mode 100644 index 0000000000..5ef91dc591 --- /dev/null +++ b/esphome/components/tmc2209/stepper.py @@ -0,0 +1,14 @@ +from esphome.components import tmc22xx +from esphome.types import ConfigType + +from . import TMC2209Stepper + +AUTO_LOAD = ["tmc22xx"] +DEPENDENCIES = ["uart"] + +CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3) +FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate + + +async def to_code(config: ConfigType) -> None: + await tmc22xx.new_tmc22xx(config) diff --git a/esphome/components/tmc2209/tmc2209.cpp b/esphome/components/tmc2209/tmc2209.cpp new file mode 100644 index 0000000000..040b362a8d --- /dev/null +++ b/esphome/components/tmc2209/tmc2209.cpp @@ -0,0 +1,13 @@ +#include "tmc2209.h" +#include "esphome/core/log.h" + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209"); + +void TMC2209Stepper::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209:"); + TMC22XXStepper::dump_config(); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h new file mode 100644 index 0000000000..2f880251e5 --- /dev/null +++ b/esphome/components/tmc2209/tmc2209.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/tmc22xx/tmc22xx.h" + +namespace esphome::tmc2209 { + +class TMC2209Stepper : public tmc22xx::TMC22XXStepper { + public: + void dump_config() override; + + protected: + uint8_t expected_version() const override { return 0x21; } +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py new file mode 100644 index 0000000000..ad7221f0b2 --- /dev/null +++ b/esphome/components/tmc22xx/__init__.py @@ -0,0 +1,211 @@ +"""Shared code for Trinamic stepper drivers configured over the single wire UART interface.""" + +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components import stepper, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_DIR_PIN, + CONF_ID, + CONF_STEP_PIN, + CONF_UART_ID, +) +from esphome.cpp_generator import MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DOMAIN = "tmc22xx" +DEPENDENCIES = ["uart"] + +CONF_ANALOG_CURRENT_SCALE = "analog_current_scale" +CONF_CLOCK_FREQUENCY = "clock_frequency" +CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" +CONF_ENN_PIN = "enn_pin" +CONF_HOLD_CURRENT = "hold_current" +CONF_IHOLD = "ihold" +CONF_IHOLDDELAY = "iholddelay" +CONF_INDEX_PIN = "index_pin" +CONF_INTERPOLATION = "interpolation" +CONF_INVERSE_DIRECTION = "inverse_direction" +CONF_IRUN = "irun" +CONF_MICROSTEPS = "microsteps" +CONF_OTTRIM = "ottrim" +CONF_RSENSE = "rsense" +CONF_RUN_CURRENT = "run_current" +CONF_STANDSTILL_MODE = "standstill_mode" +CONF_TPOWERDOWN = "tpowerdown" +CONF_TPWM_THRESHOLD = "tpwm_threshold" +CONF_VSENSE = "vsense" + +tmc22xx_ns = cg.esphome_ns.namespace("tmc22xx") +TMC22XXStepper = tmc22xx_ns.class_( + "TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice +) +StandstillMode = tmc22xx_ns.enum("StandstillMode") + +STANDSTILL_MODES = { + "normal": StandstillMode.STANDSTILL_MODE_NORMAL, + "freewheeling": StandstillMode.STANDSTILL_MODE_FREEWHEELING, + "coil_short_ls": StandstillMode.STANDSTILL_MODE_COIL_SHORT_LS, + "coil_short_hs": StandstillMode.STANDSTILL_MODE_COIL_SHORT_HS, +} + +validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True) +validate_current = cv.All(cv.current, cv.positive_float) + + +def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: + """Return the stepper schema for a family member; `max_address` is 0 for chips without address pins.""" + return cv.All( + stepper.STEPPER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(cls), + cv.Optional(CONF_ADDRESS, default=0): cv.int_range(0, max_address), + cv.Optional(CONF_ENN_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_STEP_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_DIR_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_INDEX_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_CLOCK_FREQUENCY, default="12MHz"): cv.All( + cv.frequency, cv.int_range(min=1) + ), + cv.Optional(CONF_RSENSE): cv.All( + cv.resistance, cv.float_range(min=0.0, min_included=False) + ), + cv.Optional(CONF_VSENSE): cv.boolean, + cv.Optional(CONF_OTTRIM): cv.int_range(0, 3), + cv.Optional(CONF_ANALOG_CURRENT_SCALE, default=False): cv.boolean, + cv.Optional(CONF_RUN_CURRENT): validate_current, + cv.Optional(CONF_HOLD_CURRENT): validate_current, + cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + cv.has_exactly_one_key(CONF_INDEX_PIN, CONF_STEP_PIN), + cv.has_none_or_all_keys(CONF_STEP_PIN, CONF_DIR_PIN), + _validate_analog_current_scale, + ) + + +def _validate_analog_current_scale(config: ConfigType) -> ConfigType: + if config[CONF_ANALOG_CURRENT_SCALE]: + for key in (CONF_RUN_CURRENT, CONF_HOLD_CURRENT): + if key in config: + raise cv.Invalid( + f"'{key}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', the " + f"current then depends on VREF; set '{CONF_IRUN}'/'{CONF_IHOLD}' " + "with the currents action instead", + path=[key], + ) + return config + + +def final_validate(config: ConfigType) -> ConfigType: + """Reject two drivers of the family with the same address on one UART bus.""" + for other in fv.full_config.get().get("stepper", []): + if other is config: + break + if ( + other.get(CONF_UART_ID) == config[CONF_UART_ID] + and other.get(CONF_ADDRESS) == config[CONF_ADDRESS] + ): + raise cv.Invalid( + f"Address {config[CONF_ADDRESS]} is already used by '{other[CONF_ID]}' " + "on the same UART bus" + ) + return config + + +async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + await stepper.register_stepper(var, config) + + cg.add(var.set_address(config[CONF_ADDRESS])) + cg.add(var.set_clock_frequency(config[CONF_CLOCK_FREQUENCY])) + cg.add(var.set_analog_current_scale(config[CONF_ANALOG_CURRENT_SCALE])) + cg.add(var.set_initial_microsteps(config[CONF_MICROSTEPS])) + for key, setter in ( + (CONF_ENN_PIN, var.set_enn_pin), + (CONF_STEP_PIN, var.set_step_pin), + (CONF_DIR_PIN, var.set_dir_pin), + (CONF_INDEX_PIN, var.set_index_pin), + ): + if (pin := config.get(key)) is not None: + cg.add(setter(await cg.gpio_pin_expression(pin))) + for key, setter in ( + (CONF_RSENSE, var.set_rsense), + (CONF_VSENSE, var.set_vsense), + (CONF_OTTRIM, var.set_ottrim), + (CONF_RUN_CURRENT, var.set_initial_run_current), + (CONF_HOLD_CURRENT, var.set_initial_hold_current), + ): + if (value := config.get(key)) is not None: + cg.add(setter(value)) + return var + + +def register_actions(domain: str, cls: MockObjClass) -> None: + """Register the actions shared by the family under `domain`, e.g. `tmc2209.enable`.""" + id_schema = {cv.GenerateID(): cv.use_id(cls)} + for name, enabled in (("enable", "true"), ("disable", "false")): + automation.register_apply_action( + f"{domain}.{name}", + automation.maybe_simple_id(id_schema), + automation.ApplyCall(f"set_enabled({enabled})"), + ) + + automation.register_apply_action( + f"{domain}.configure", + cv.Schema( + { + **id_schema, + cv.Optional(CONF_INVERSE_DIRECTION): cv.templatable(cv.boolean), + cv.Optional(CONF_MICROSTEPS): cv.templatable(validate_microsteps), + cv.Optional(CONF_INTERPOLATION): cv.templatable(cv.boolean), + cv.Optional(CONF_ENABLE_SPREADCYCLE): cv.templatable(cv.boolean), + cv.Optional(CONF_TPWM_THRESHOLD): cv.templatable( + cv.int_range(0, 2**20 - 1) + ), + } + ), + automation.ApplyField( + CONF_INVERSE_DIRECTION, "set_inverse_direction", cg.bool_ + ), + automation.ApplyField(CONF_MICROSTEPS, "set_microsteps", cg.uint16), + automation.ApplyField(CONF_INTERPOLATION, "set_interpolation", cg.bool_), + automation.ApplyField(CONF_ENABLE_SPREADCYCLE, "set_spreadcycle", cg.bool_), + automation.ApplyField(CONF_TPWM_THRESHOLD, "set_tpwm_threshold", cg.uint32), + ) + + automation.register_apply_action( + f"{domain}.currents", + cv.Schema( + { + **id_schema, + cv.Exclusive(CONF_RUN_CURRENT, "run"): cv.templatable(validate_current), + cv.Exclusive(CONF_IRUN, "run"): cv.templatable(cv.int_range(0, 31)), + cv.Exclusive(CONF_HOLD_CURRENT, "hold"): cv.templatable( + validate_current + ), + cv.Exclusive(CONF_IHOLD, "hold"): cv.templatable(cv.int_range(0, 31)), + cv.Optional(CONF_IHOLDDELAY): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_TPOWERDOWN): cv.templatable(cv.int_range(0, 255)), + cv.Optional(CONF_STANDSTILL_MODE): cv.templatable( + cv.enum(STANDSTILL_MODES, lower=True) + ), + } + ), + automation.ApplyField(CONF_RUN_CURRENT, "set_run_current", cg.float_), + automation.ApplyField(CONF_IRUN, "set_irun", cg.uint8), + automation.ApplyField(CONF_HOLD_CURRENT, "set_hold_current", cg.float_), + automation.ApplyField(CONF_IHOLD, "set_ihold", cg.uint8), + automation.ApplyField(CONF_IHOLDDELAY, "set_iholddelay", cg.uint8), + automation.ApplyField(CONF_TPOWERDOWN, "set_tpowerdown", cg.uint8), + automation.ApplyField( + CONF_STANDSTILL_MODE, "set_standstill_mode", StandstillMode + ), + ) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp new file mode 100644 index 0000000000..1628572d40 --- /dev/null +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -0,0 +1,372 @@ +#include "tmc22xx.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::tmc22xx { + +ESPHOME_LOG_TAG(TAG, "tmc22xx"); + +static constexpr uint8_t SYNC_BYTE = 0x05; +static constexpr uint8_t MASTER_ADDRESS = 0xFF; +static constexpr uint8_t WRITE_BIT = 0x80; +static constexpr size_t READ_REQUEST_SIZE = 4; +static constexpr size_t DATAGRAM_SIZE = 8; +static constexpr uint8_t DEFAULT_TOFF = 3; +static constexpr float SENSE_RESISTOR_OFFSET = 0.02f; // Ohm, added to RSENSE in the datasheet current formula +static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resistance when using internal sensing +// INDEX pulses are counted once per loop, so near the target the speed is limited to cover the remaining +// distance in no less than this time. A slow loop pass then cannot run far past the target. +static constexpr float APPROACH_TIME_S = 0.02f; +// Skip VACTUAL writes that change the speed by less than 1/16, each write blocks the loop for the UART transfer +static constexpr int32_t VACTUAL_CHANGE_DIVISOR = 16; + +// CRC-8 with polynomial 0x07 over the datagram bits in the order they go out on the wire, LSB first +static uint8_t datagram_crc(const uint8_t *data, size_t len) { + uint8_t crc = 0; + for (size_t i = 0; i < len; i++) { + const uint8_t reflected = reverse_bits(data[i]); + crc = crc8(&reflected, 1, crc, 0x07, true); + } + return crc; +} + +void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; } + +void TMC22XXStepper::setup() { + if (this->enn_pin_ != nullptr) { + this->enn_pin_->setup(); + this->enn_pin_->digital_write(true); + } + + auto version = this->read_field(VERSION); + if (!version.has_value()) { + ESP_LOGE(TAG, "No response from driver at address %u", this->address_); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->version_ = *version; + if (this->version_ != this->expected_version()) { + ESP_LOGW(TAG, "Unexpected IC version 0x%02X", this->version_); + } + + // Bring write-only registers in line with their cached values and clear the reset flag + this->write_register(REG_IHOLD_IRUN, this->ihold_irun_); + this->write_register(REG_TPOWERDOWN, this->tpowerdown_); + this->write_register(REG_TPWMTHRS, this->tpwmthrs_); + this->write_register(REG_VACTUAL, 0); + this->write_register(REG_GSTAT, 0x07); + + this->write_field(PDN_DISABLE, true); + this->write_field(MSTEP_REG_SELECT, true); + this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_)); + this->write_field(I_SCALE_ANALOG, this->analog_current_scale_); + if (this->vsense_.has_value()) + this->write_field(VSENSE, *this->vsense_); + if (this->ottrim_.has_value()) + this->write_field(OTTRIM, *this->ottrim_); + this->vsense_active_ = this->read_field(VSENSE).value_or(0); + + // TOFF=0 disables the outputs, keep the current value so it can be restored on enable + auto toff = this->read_field(TOFF); + if (toff.has_value() && *toff != 0) + this->toff_ = *toff; + + this->set_microsteps(this->initial_microsteps_); + if (!std::isnan(this->initial_run_current_)) + this->set_run_current(this->initial_run_current_); + if (!std::isnan(this->initial_hold_current_)) + this->set_hold_current(this->initial_hold_current_); + + if (this->index_pin_ != nullptr) { + // INDEX toggles on every step of the internal pulse generator that VACTUAL drives + this->write_field(INDEX_OTPW, false); + this->write_field(INDEX_STEP, true); + this->index_pin_->setup(); + this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); + } else { + // Step on both edges, so every step pin toggle is one step + this->write_field(DEDGE, true); + this->write_field(MULTISTEP_FILT, false); + this->step_pin_->setup(); + this->step_pin_->digital_write(false); + this->dir_pin_->setup(); + this->dir_pin_->digital_write(false); + } + + this->set_enabled(true); +} + +void TMC22XXStepper::dump_config() { + ESP_LOGCONFIG(TAG, + " Address: %u\n" + " IC version: 0x%02X", + this->address_, this->version_); + // Register reads on a failed driver would only wait for the UART timeout + if (!this->is_failed()) { + uint8_t irun = this->read_field(IRUN).value_or(0); + uint8_t ihold = this->read_field(IHOLD).value_or(0); + ESP_LOGCONFIG(TAG, " Microsteps: %u", this->get_microsteps()); + if (this->analog_current_scale_) { + ESP_LOGCONFIG(TAG, + " Run current scale: %u\n" + " Hold current scale: %u\n" + " Current scaled by VREF", + irun, ihold); + } else { + ESP_LOGCONFIG(TAG, + " Run current: %.2f A\n" + " Hold current: %.2f A\n" + " Max current: %.2f A", + this->scale_to_current(irun), this->scale_to_current(ihold), this->scale_to_current(31)); + } + } + LOG_PIN(" ENN Pin: ", this->enn_pin_); + LOG_PIN(" STEP Pin: ", this->step_pin_); + LOG_PIN(" DIR Pin: ", this->dir_pin_); + LOG_PIN(" INDEX Pin: ", this->index_pin_); + LOG_STEPPER(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void TMC22XXStepper::loop() { + if (this->index_pin_ != nullptr) + this->add_index_pulses_(); + if (this->has_reached_target()) { + this->high_freq_.stop(); + } else { + this->high_freq_.start(); + if (!this->enabled_) + this->set_enabled(true); + } + + if (this->index_pin_ != nullptr) { + this->loop_serial_(); + } else { + this->loop_step_dir_(); + } +} + +void TMC22XXStepper::add_index_pulses_() { + InterruptLock lock; + this->current_position += this->index_store_.pulses; + this->index_store_.pulses = 0; +} + +void TMC22XXStepper::loop_serial_() { + int32_t remaining = this->target_position - this->current_position; + int8_t direction = (remaining > 0) - (remaining < 0); + // Ran past the target, restart the ramp from standstill in the other direction + if (direction != 0 && direction == -this->direction_) + this->current_speed_ = 0.0f; + this->calculate_speed_(micros()); + this->current_speed_ = std::min(this->current_speed_, static_cast(std::abs(remaining)) / APPROACH_TIME_S); + + int32_t vactual = direction * this->speed_to_vactual_(this->current_speed_); + int32_t last = this->get_vactual_(); + if (vactual == last) + return; + if (vactual != 0 && (vactual > 0) == (last > 0) && std::abs(vactual - last) < std::abs(last) / VACTUAL_CHANGE_DIVISOR) + return; + // Pulses that arrive while VACTUAL changes direction are counted in the new direction + this->index_store_.direction = direction; + this->direction_ = direction; + this->write_field(VACTUAL, static_cast(vactual)); +} + +void TMC22XXStepper::loop_step_dir_() { + int32_t direction = this->should_step_(); + if (direction == 0) + return; + if (direction != this->direction_) { + this->dir_pin_->digital_write(direction > 0); + this->direction_ = direction; + delayMicroseconds(1); + } + this->step_state_ = !this->step_state_; + this->step_pin_->digital_write(this->step_state_); +} + +void TMC22XXStepper::on_shutdown() { + if (this->get_vactual_() != 0) + this->write_field(VACTUAL, 0); +} + +void TMC22XXStepper::set_enabled(bool enabled) { + if (!enabled) { + if (this->get_vactual_() != 0) + this->write_field(VACTUAL, 0); + if (this->index_pin_ != nullptr) { + // Count the pulses up to the stop, so the position is current before the target is set + this->add_index_pulses_(); + this->index_store_.direction = 0; + this->direction_ = 0; + } + // Stop at the current position, so the motor does not run off again when re-enabled + this->target_position = this->current_position; + } + if (this->enn_pin_ != nullptr) { + this->enn_pin_->digital_write(!enabled); + } else { + this->write_field(TOFF, enabled ? this->toff_ : 0); + } + this->enabled_ = enabled; +} + +void TMC22XXStepper::set_microsteps(uint16_t microsteps) { + for (uint8_t mres = 0; mres <= 8; mres++) { + if ((256u >> mres) == microsteps) { + this->write_field(MRES, mres); + return; + } + } + ESP_LOGW(TAG, "Invalid microsteps %u", microsteps); +} + +uint16_t TMC22XXStepper::get_microsteps() { + auto mres = this->read_field(MRES); + if (!mres.has_value() || *mres > 8) + return 0; + return 256u >> *mres; +} + +void TMC22XXStepper::set_run_current(float current) { + if (auto scale = this->current_to_scale_(current)) + this->write_field(IRUN, *scale); +} + +void TMC22XXStepper::set_hold_current(float current) { + if (auto scale = this->current_to_scale_(current)) + this->write_field(IHOLD, *scale); +} + +float TMC22XXStepper::full_scale_current_() { + return (this->vsense_active_ ? 0.180f : 0.325f) / + ((std::isnan(this->rsense_) ? INTERNAL_RSENSE_OHM : this->rsense_) + SENSE_RESISTOR_OFFSET); +} + +float TMC22XXStepper::scale_to_current(uint8_t scale) { + return (std::min(scale, 31) + 1) / 32.0f * this->full_scale_current_() / std::numbers::sqrt2_v; +} + +optional TMC22XXStepper::current_to_scale_(float current) { + if (this->analog_current_scale_) { + ESP_LOGW(TAG, "Current in A is not supported with analog current scale, use irun/ihold"); + return {}; + } + float scale = 32.0f * std::numbers::sqrt2_v * current / this->full_scale_current_() - 1.0f; + if (scale > 31.0f) { + ESP_LOGW(TAG, "%.2f A is above the %.2f A limit, using the limit", current, this->scale_to_current(31)); + return 31; + } + return static_cast(std::max(0.0f, std::round(scale))); +} + +int32_t TMC22XXStepper::speed_to_vactual_(float speed) const { + // VACTUAL is in microsteps per 2^24 clock cycles + return static_cast(std::lround(speed * 16777216.0f / this->clock_frequency_)); +} + +uint32_t *TMC22XXStepper::shadow_register(uint8_t reg) { + switch (reg) { + case REG_IHOLD_IRUN: + return &this->ihold_irun_; + case REG_TPOWERDOWN: + return &this->tpowerdown_; + case REG_TPWMTHRS: + return &this->tpwmthrs_; + case REG_VACTUAL: + return &this->vactual_reg_; + default: + return nullptr; + } +} + +bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { + uint8_t datagram[DATAGRAM_SIZE] = {SYNC_BYTE, + this->address_, + static_cast(reg | WRITE_BIT), + static_cast(value >> 24), + static_cast(value >> 16), + static_cast(value >> 8), + static_cast(value), + 0}; + datagram[7] = datagram_crc(datagram, 7); + + while (this->available()) + this->read(); + this->write_array(datagram, DATAGRAM_SIZE); + this->flush(); + // On the single wire bus every byte sent is also received, drop that echo + uint8_t echo[DATAGRAM_SIZE]; + if (!this->read_array(echo, DATAGRAM_SIZE) || memcmp(echo, datagram, DATAGRAM_SIZE) != 0) { + ESP_LOGV(TAG, "No echo writing register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->status_clear_warning(); + + if (uint32_t *shadow = this->shadow_register(reg)) + *shadow = value; + return true; +} + +optional TMC22XXStepper::read_register(uint8_t reg) { + if (uint32_t *shadow = this->shadow_register(reg)) + return *shadow; + + uint8_t request[READ_REQUEST_SIZE] = {SYNC_BYTE, this->address_, reg, 0}; + request[3] = datagram_crc(request, 3); + + while (this->available()) + this->read(); + this->write_array(request, READ_REQUEST_SIZE); + this->flush(); + uint8_t reply[DATAGRAM_SIZE]; + if (!this->read_array(reply, READ_REQUEST_SIZE) || !this->read_array(reply, DATAGRAM_SIZE)) { + ESP_LOGV(TAG, "No reply reading register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return {}; + } + if (reply[0] != SYNC_BYTE || reply[1] != MASTER_ADDRESS || reply[2] != reg || reply[7] != datagram_crc(reply, 7)) { + ESP_LOGV(TAG, "Invalid reply reading register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return {}; + } + this->status_clear_warning(); + return encode_uint32(reply[3], reply[4], reply[5], reply[6]); +} + +bool TMC22XXStepper::write_field(const RegisterField &field, uint32_t value) { + // Skip the UART timeout when the driver never answered during setup + if (this->is_failed()) + return false; + auto data = this->read_register(field.reg); + if (!data.has_value()) + return false; + uint32_t mask = field.mask(); + return this->write_register(field.reg, (*data & ~mask) | ((value << field.shift) & mask)); +} + +optional TMC22XXStepper::read_field(const RegisterField &field) { + auto data = this->read_register(field.reg); + if (!data.has_value()) + return {}; + return extract_field(*data, field); +} + +uint32_t TMC22XXStepper::extract_field(uint32_t data, const RegisterField &field) { + uint32_t value = (data & field.mask()) >> field.shift; + if (field.is_signed && field.width < 32) { + uint32_t sign_bit = 1u << (field.width - 1); + value = (value ^ sign_bit) - sign_bit; + } + return value; +} + +} // namespace esphome::tmc22xx diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h new file mode 100644 index 0000000000..8b0c92d352 --- /dev/null +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -0,0 +1,167 @@ +#pragma once + +#include + +#include "esphome/components/stepper/stepper.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tmc22xx { + +/// A bit field inside one register. +struct RegisterField { + uint8_t reg; + uint8_t shift; + uint8_t width; + bool is_signed; + + constexpr uint32_t mask() const { return ((width >= 32) ? 0xFFFFFFFFu : ((1u << width) - 1u)) << shift; } +}; + +// Registers shared by the TMC22xx family (TMC2202, TMC2208, TMC2209, TMC2224, TMC2225, TMC2226) +static constexpr uint8_t REG_GCONF = 0x00; +static constexpr uint8_t REG_GSTAT = 0x01; +static constexpr uint8_t REG_IOIN = 0x06; +static constexpr uint8_t REG_FACTORY_CONF = 0x07; +static constexpr uint8_t REG_IHOLD_IRUN = 0x10; +static constexpr uint8_t REG_TPOWERDOWN = 0x11; +static constexpr uint8_t REG_TPWMTHRS = 0x13; +static constexpr uint8_t REG_VACTUAL = 0x22; +static constexpr uint8_t REG_CHOPCONF = 0x6C; +static constexpr uint8_t REG_PWMCONF = 0x70; + +static constexpr RegisterField I_SCALE_ANALOG{REG_GCONF, 0, 1, false}; +static constexpr RegisterField INTERNAL_RSENSE{REG_GCONF, 1, 1, false}; +static constexpr RegisterField EN_SPREADCYCLE{REG_GCONF, 2, 1, false}; +static constexpr RegisterField SHAFT{REG_GCONF, 3, 1, false}; +static constexpr RegisterField INDEX_OTPW{REG_GCONF, 4, 1, false}; +static constexpr RegisterField INDEX_STEP{REG_GCONF, 5, 1, false}; +static constexpr RegisterField PDN_DISABLE{REG_GCONF, 6, 1, false}; +static constexpr RegisterField MSTEP_REG_SELECT{REG_GCONF, 7, 1, false}; +static constexpr RegisterField MULTISTEP_FILT{REG_GCONF, 8, 1, false}; +static constexpr RegisterField VERSION{REG_IOIN, 24, 8, false}; +static constexpr RegisterField OTTRIM{REG_FACTORY_CONF, 8, 2, false}; +static constexpr RegisterField IHOLD{REG_IHOLD_IRUN, 0, 5, false}; +static constexpr RegisterField IRUN{REG_IHOLD_IRUN, 8, 5, false}; +static constexpr RegisterField IHOLDDELAY{REG_IHOLD_IRUN, 16, 4, false}; +static constexpr RegisterField TPOWERDOWN{REG_TPOWERDOWN, 0, 8, false}; +static constexpr RegisterField TPWMTHRS{REG_TPWMTHRS, 0, 20, false}; +static constexpr RegisterField VACTUAL{REG_VACTUAL, 0, 24, true}; +static constexpr RegisterField TOFF{REG_CHOPCONF, 0, 4, false}; +static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false}; +static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false}; +static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false}; +static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false}; +static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; + +enum StandstillMode : uint8_t { + STANDSTILL_MODE_NORMAL = 0, + STANDSTILL_MODE_FREEWHEELING = 1, + STANDSTILL_MODE_COIL_SHORT_LS = 2, + STANDSTILL_MODE_COIL_SHORT_HS = 3, +}; + +/// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL. +struct IndexPulseStore { + volatile int32_t pulses{0}; + volatile int8_t direction{0}; + static void gpio_intr(IndexPulseStore *arg); +}; + +/// Base class for Trinamic stepper drivers that are configured over the single wire UART interface. +class TMC22XXStepper : public stepper::Stepper, public Component, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_address(uint8_t address) { this->address_ = address; } + void set_enn_pin(GPIOPin *pin) { this->enn_pin_ = pin; } + void set_step_pin(GPIOPin *pin) { this->step_pin_ = pin; } + void set_dir_pin(GPIOPin *pin) { this->dir_pin_ = pin; } + void set_index_pin(InternalGPIOPin *pin) { this->index_pin_ = pin; } + void set_clock_frequency(uint32_t frequency) { this->clock_frequency_ = frequency; } + void set_rsense(float rsense) { this->rsense_ = rsense; } + void set_vsense(bool vsense) { this->vsense_ = vsense; } + void set_ottrim(uint8_t ottrim) { this->ottrim_ = ottrim; } + void set_analog_current_scale(bool enable) { this->analog_current_scale_ = enable; } + void set_initial_run_current(float current) { this->initial_run_current_ = current; } + void set_initial_hold_current(float current) { this->initial_hold_current_ = current; } + void set_initial_microsteps(uint16_t microsteps) { this->initial_microsteps_ = microsteps; } + + /// Enable or disable the motor outputs, using the ENN pin when configured and TOFF otherwise. + void set_enabled(bool enabled); + bool is_enabled() const { return this->enabled_; } + + void set_microsteps(uint16_t microsteps); + uint16_t get_microsteps(); + void set_interpolation(bool enable) { this->write_field(INTPOL, enable); } + void set_spreadcycle(bool enable) { this->write_field(EN_SPREADCYCLE, enable); } + void set_inverse_direction(bool inverse) { this->write_field(SHAFT, inverse); } + void set_tpwm_threshold(uint32_t threshold) { this->write_field(TPWMTHRS, threshold); } + + /// Set the motor run or hold current in A RMS. + /// Not supported with analog current scale, use set_irun() and set_ihold() instead. + void set_run_current(float current); + void set_hold_current(float current); + void set_irun(uint8_t irun) { this->write_field(IRUN, irun); } + void set_ihold(uint8_t ihold) { this->write_field(IHOLD, ihold); } + void set_iholddelay(uint8_t delay) { this->write_field(IHOLDDELAY, delay); } + void set_tpowerdown(uint8_t tpowerdown) { this->write_field(TPOWERDOWN, tpowerdown); } + void set_standstill_mode(StandstillMode mode) { this->write_field(FREEWHEEL, mode); } + /// Convert a current scale (0-31) to A RMS, without analog current scale. + float scale_to_current(uint8_t scale); + + bool write_register(uint8_t reg, uint32_t value); + optional read_register(uint8_t reg); + bool write_field(const RegisterField &field, uint32_t value); + optional read_field(const RegisterField &field); + static uint32_t extract_field(uint32_t data, const RegisterField &field); + + protected: + /// IC version reported in IOIN by the supported chip. + virtual uint8_t expected_version() const = 0; + /// Return the cached value of a write-only register, or nullptr when the register is readable. + virtual uint32_t *shadow_register(uint8_t reg); + optional current_to_scale_(float current); + float full_scale_current_(); + int32_t get_vactual_() const { return static_cast(extract_field(this->vactual_reg_, VACTUAL)); } + int32_t speed_to_vactual_(float speed) const; + void add_index_pulses_(); + void loop_serial_(); + void loop_step_dir_(); + + GPIOPin *enn_pin_{nullptr}; + GPIOPin *step_pin_{nullptr}; + GPIOPin *dir_pin_{nullptr}; + InternalGPIOPin *index_pin_{nullptr}; + uint32_t clock_frequency_{12000000}; + // Write-only registers shared by all family members, with their power-on values + uint32_t ihold_irun_{0x00071703}; + uint32_t tpowerdown_{0x14}; + uint32_t tpwmthrs_{0}; + uint32_t vactual_reg_{0}; + // NaN while not configured + float rsense_{NAN}; + float initial_run_current_{NAN}; + float initial_hold_current_{NAN}; + IndexPulseStore index_store_{}; + uint16_t initial_microsteps_{16}; + optional vsense_{}; + optional ottrim_{}; + uint8_t address_{0}; + uint8_t toff_{3}; + uint8_t version_{0}; + int8_t direction_{0}; + bool analog_current_scale_{false}; + bool enabled_{false}; + bool vsense_active_{false}; + bool step_state_{false}; + HighFrequencyLoopRequester high_freq_; +}; + +} // namespace esphome::tmc22xx diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml new file mode 100644 index 0000000000..ff10c79edc --- /dev/null +++ b/tests/components/tmc2209/common.yaml @@ -0,0 +1,57 @@ +stepper: + - platform: tmc2209 + id: tmc2209_step_dir + uart_id: uart_bus + address: 0 + step_pin: ${step_pin} + dir_pin: ${dir_pin} + enn_pin: ${enn_pin} + rsense: 110 mOhm + vsense: false + ottrim: 1 + run_current: 800mA + hold_current: 400mA + microsteps: 16 + max_speed: 2000 steps/s + acceleration: 1000 steps/s^2 + deceleration: 1000 steps/s^2 + + - platform: tmc2209 + id: tmc2209_serial + uart_id: uart_bus + address: 1 + index_pin: ${index_pin} + clock_frequency: 12MHz + analog_current_scale: true + max_speed: 1000 steps/s + +esphome: + on_boot: + then: + - tmc2209.configure: + id: tmc2209_step_dir + inverse_direction: true + microsteps: 8 + interpolation: true + enable_spreadcycle: false + tpwm_threshold: 0 + - tmc2209.configure: + id: tmc2209_serial + microsteps: !lambda "return 32;" + - tmc2209.currents: + id: tmc2209_step_dir + run_current: 1A + hold_current: 0.5A + iholddelay: 4 + tpowerdown: 20 + standstill_mode: freewheeling + - tmc2209.currents: + id: tmc2209_serial + irun: 20 + ihold: !lambda "return 5;" + - tmc2209.enable: tmc2209_serial + - stepper.set_target: + id: tmc2209_serial + target: 1000 + - tmc2209.disable: + id: tmc2209_step_dir diff --git a/tests/components/tmc2209/test.esp32-idf.yaml b/tests/components/tmc2209/test.esp32-idf.yaml new file mode 100644 index 0000000000..5634c18686 --- /dev/null +++ b/tests/components/tmc2209/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO12 + dir_pin: GPIO13 + enn_pin: GPIO14 + index_pin: GPIO15 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + tmc2209: !include common.yaml diff --git a/tests/components/tmc2209/test.esp8266-ard.yaml b/tests/components/tmc2209/test.esp8266-ard.yaml new file mode 100644 index 0000000000..3e4ea33f7a --- /dev/null +++ b/tests/components/tmc2209/test.esp8266-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO12 + dir_pin: GPIO13 + enn_pin: GPIO14 + index_pin: GPIO15 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + tmc2209: !include common.yaml diff --git a/tests/components/tmc2209/test.rp2040-ard.yaml b/tests/components/tmc2209/test.rp2040-ard.yaml new file mode 100644 index 0000000000..722f8f201f --- /dev/null +++ b/tests/components/tmc2209/test.rp2040-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO2 + dir_pin: GPIO3 + enn_pin: GPIO6 + index_pin: GPIO7 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + tmc2209: !include common.yaml From fa519cea645745d3e08de08d8233c4bfbf6627da Mon Sep 17 00:00:00 2001 From: Ilya Esin Date: Mon, 12 Oct 2026 23:14:31 +0000 Subject: [PATCH 231/268] [apds9930] Add ambient light and proximity sensor (#13125) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/apds9930/__init__.py | 2 + esphome/components/apds9930/apds9930.cpp | 140 ++++++++++++++++++ esphome/components/apds9930/apds9930.h | 30 ++++ esphome/components/apds9930/sensor.py | 70 +++++++++ tests/components/apds9930/common.yaml | 13 ++ tests/components/apds9930/test.esp32-idf.yaml | 3 + .../components/apds9930/test.esp8266-ard.yaml | 3 + .../components/apds9930/test.rp2040-ard.yaml | 3 + 9 files changed, 265 insertions(+) create mode 100644 esphome/components/apds9930/__init__.py create mode 100644 esphome/components/apds9930/apds9930.cpp create mode 100644 esphome/components/apds9930/apds9930.h create mode 100644 esphome/components/apds9930/sensor.py create mode 100644 tests/components/apds9930/common.yaml create mode 100644 tests/components/apds9930/test.esp32-idf.yaml create mode 100644 tests/components/apds9930/test.esp8266-ard.yaml create mode 100644 tests/components/apds9930/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2c637b0d20..99a12f839f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -40,6 +40,7 @@ esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix esphome/components/apds9306/* @aodrenah +esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core esphome/components/aqi/* @freekode @jasstrong @ximex esphome/components/as5600/* @ammmze diff --git a/esphome/components/apds9930/__init__.py b/esphome/components/apds9930/__init__.py new file mode 100644 index 0000000000..0e6330bd88 --- /dev/null +++ b/esphome/components/apds9930/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@iyesin"] +DOMAIN = "apds9930" diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp new file mode 100644 index 0000000000..6debc0d9c7 --- /dev/null +++ b/esphome/components/apds9930/apds9930.cpp @@ -0,0 +1,140 @@ +#include "apds9930.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::apds9930 { + +ESPHOME_LOG_TAG(TAG, "apds9930"); + +// Register addresses +static constexpr uint8_t APDS9930_ENABLE = 0x00; +static constexpr uint8_t APDS9930_ATIME = 0x01; +static constexpr uint8_t APDS9930_PTIME = 0x02; +static constexpr uint8_t APDS9930_PPULSE = 0x0E; +static constexpr uint8_t APDS9930_CONTROL = 0x0F; +static constexpr uint8_t APDS9930_ID = 0x12; +static constexpr uint8_t APDS9930_STATUS = 0x13; + +// Command byte: repeated byte and auto-increment protocols +static constexpr uint8_t APDS9930_CMD = 0x80; +static constexpr uint8_t APDS9930_CMD_AUTO_INCREMENT = 0xA0; + +static constexpr uint8_t APDS9930_CHIP_ID = 0x39; + +// ENABLE register bits +static constexpr uint8_t APDS9930_PON = 0x01; +static constexpr uint8_t APDS9930_AEN = 0x02; +static constexpr uint8_t APDS9930_PEN = 0x04; + +// STATUS register bits +static constexpr uint8_t APDS9930_AVALID = 0x01; +static constexpr uint8_t APDS9930_PVALID = 0x02; + +// CONTROL register fields +static constexpr uint8_t CONTROL_PDRIVE_SHIFT = 6; +static constexpr uint8_t CONTROL_PDIODE_SHIFT = 4; +static constexpr uint8_t CONTROL_PGAIN_SHIFT = 2; +static constexpr uint8_t CONTROL_AGAIN_SHIFT = 0; +static constexpr uint8_t CONTROL_PDIODE_CH1 = 0b10; // The only supported proximity diode + +static constexpr uint8_t APDS9930_ATIME_VALUE = 0xED; // 19 cycles, about 52 ms +static constexpr uint8_t APDS9930_PTIME_VALUE = 0xFF; // 2.73 ms +static constexpr uint8_t APDS9930_PPULSE_VALUE = 0x08; // 8 pulses + +// Lux calculation coefficients +static constexpr float APDS9930_DF = 52.0f; +static constexpr float APDS9930_GA = 0.49f; +static constexpr float APDS9930_ALS_B = 1.862f; +static constexpr float APDS9930_ALS_C = 0.746f; +static constexpr float APDS9930_ALS_D = 1.291f; +static constexpr float APDS9930_ALSIT_MS = 2.73f * (256 - APDS9930_ATIME_VALUE); + +static constexpr uint8_t AMBIENT_GAINS[4] PROGMEM = {1, 8, 16, 120}; +static constexpr uint8_t PROXIMITY_GAINS[4] PROGMEM = {1, 2, 4, 8}; +static constexpr float LED_CURRENT_MAX_MA = 100.0f; // halved for each drive step + +// Lux per count at 1x gain and the fixed integration time +static constexpr float LUX_PER_COUNT_1X = APDS9930_GA * APDS9930_DF / APDS9930_ALSIT_MS; +// Each integration cycle adds up to 1024 counts, so this is the ceiling of both light channels +static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE); + +bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); } + +void APDS9930Component::setup() { + uint8_t id; + if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) { + this->mark_failed(LOG_STR("Communication failed")); + return; + } + if (id != APDS9930_CHIP_ID) { + ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID); + this->mark_failed(LOG_STR("Wrong chip ID")); + return; + } + + const uint8_t control = + (this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT | + (this->proximity_gain_ & 0b11) << CONTROL_PGAIN_SHIFT | (this->ambient_gain_ & 0b11) << CONTROL_AGAIN_SHIFT; + + uint8_t enable = APDS9930_PON; + if (this->illuminance_sensor_ != nullptr) + enable |= APDS9930_AEN; + if (this->proximity_sensor_ != nullptr) + enable |= APDS9930_PEN; + + if (!this->write_reg_(APDS9930_ENABLE, 0x00) || !this->write_reg_(APDS9930_ATIME, APDS9930_ATIME_VALUE) || + !this->write_reg_(APDS9930_PTIME, APDS9930_PTIME_VALUE) || + !this->write_reg_(APDS9930_PPULSE, APDS9930_PPULSE_VALUE) || !this->write_reg_(APDS9930_CONTROL, control) || + !this->write_reg_(APDS9930_ENABLE, enable)) { + this->mark_failed(LOG_STR("Configuration failed")); + } +} + +void APDS9930Component::dump_config() { + ESP_LOGCONFIG(TAG, + "APDS9930:\n" + " LED Drive: %.1f mA\n" + " Proximity Gain: %ux\n" + " Ambient Light Gain: %ux", + LED_CURRENT_MAX_MA / (1 << this->led_drive_), + progmem_read_byte(&PROXIMITY_GAINS[this->proximity_gain_]), + progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_])); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Proximity", this->proximity_sensor_); +} + +void APDS9930Component::update() { + // STATUS, CH0DATAL/H, CH1DATAL/H, PDATAL/H + uint8_t raw[7]; + if (!this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw))) { + this->status_set_warning(LOG_STR("Reading data failed")); + return; + } + this->status_clear_warning(); + const uint8_t status = raw[0]; + + if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) { + const uint16_t ch0 = encode_uint16(raw[2], raw[1]); + const uint16_t ch1 = encode_uint16(raw[4], raw[3]); + if (ch0 >= ALS_SATURATION || ch1 >= ALS_SATURATION) { + // A saturated channel would give a lux value that is too low, not too high + ESP_LOGW(TAG, "Light channel saturated, lower ambient_light_gain"); + this->illuminance_sensor_->publish_state(NAN); + } else { + const float iac = std::max({ch0 - APDS9930_ALS_B * ch1, APDS9930_ALS_C * ch0 - APDS9930_ALS_D * ch1, 0.0f}); + this->illuminance_sensor_->publish_state(iac * LUX_PER_COUNT_1X / + progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_])); + } + } + + if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) { + this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5])); + } +} + +} // namespace esphome::apds9930 diff --git a/esphome/components/apds9930/apds9930.h b/esphome/components/apds9930/apds9930.h new file mode 100644 index 0000000000..cc7f77d3d0 --- /dev/null +++ b/esphome/components/apds9930/apds9930.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::apds9930 { + +class APDS9930Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(illuminance) + SUB_SENSOR(proximity) + + public: + void setup() override; + void dump_config() override; + void update() override; + + void set_led_drive(uint8_t level) { this->led_drive_ = level; } + void set_proximity_gain(uint8_t gain) { this->proximity_gain_ = gain; } + void set_ambient_gain(uint8_t gain) { this->ambient_gain_ = gain; } + + protected: + bool write_reg_(uint8_t reg, uint8_t value); + + uint8_t led_drive_{0}; + uint8_t proximity_gain_{0}; + uint8_t ambient_gain_{0}; +}; + +} // namespace esphome::apds9930 diff --git a/esphome/components/apds9930/sensor.py b/esphome/components/apds9930/sensor.py new file mode 100644 index 0000000000..2b169271c6 --- /dev/null +++ b/esphome/components/apds9930/sensor.py @@ -0,0 +1,70 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_ILLUMINANCE, + DEVICE_CLASS_ILLUMINANCE, + STATE_CLASS_MEASUREMENT, + UNIT_LUX, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +CONF_AMBIENT_LIGHT_GAIN = "ambient_light_gain" +CONF_LED_DRIVE = "led_drive" +CONF_PROXIMITY = "proximity" +CONF_PROXIMITY_GAIN = "proximity_gain" + +DRIVE_LEVELS = {"100ma": 0, "50ma": 1, "25ma": 2, "12.5ma": 3} +PROXIMITY_LEVELS = {"1x": 0, "2x": 1, "4x": 2, "8x": 3} +AMBIENT_LEVELS = {"1x": 0, "8x": 1, "16x": 2, "120x": 3} + +apds9930_ns = cg.esphome_ns.namespace("apds9930") +APDS9930Component = apds9930_ns.class_( + "APDS9930Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(APDS9930Component), + cv.Optional(CONF_LED_DRIVE, default="100ma"): cv.enum( + DRIVE_LEVELS, lower=True + ), + cv.Optional(CONF_PROXIMITY_GAIN, default="8x"): cv.enum( + PROXIMITY_LEVELS, lower=True + ), + cv.Optional(CONF_AMBIENT_LIGHT_GAIN, default="1x"): cv.enum( + AMBIENT_LEVELS, lower=True + ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PROXIMITY): sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x39)) +) + + +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) + cg.add(var.set_led_drive(config[CONF_LED_DRIVE])) + cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN])) + cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN])) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor) + await sensors(CONF_PROXIMITY, var.set_proximity_sensor) diff --git a/tests/components/apds9930/common.yaml b/tests/components/apds9930/common.yaml new file mode 100644 index 0000000000..2cb9547e5c --- /dev/null +++ b/tests/components/apds9930/common.yaml @@ -0,0 +1,13 @@ +sensor: + - platform: apds9930 + i2c_id: i2c_bus + id: apds9930_sensor + address: 0x39 + update_interval: 30s + led_drive: 25ma + proximity_gain: 4x + ambient_light_gain: 16x + illuminance: + name: APDS9930 Illuminance + proximity: + name: APDS9930 Proximity diff --git a/tests/components/apds9930/test.esp32-idf.yaml b/tests/components/apds9930/test.esp32-idf.yaml new file mode 100644 index 0000000000..8226f36aa7 --- /dev/null +++ b/tests/components/apds9930/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + apds9930: !include common.yaml diff --git a/tests/components/apds9930/test.esp8266-ard.yaml b/tests/components/apds9930/test.esp8266-ard.yaml new file mode 100644 index 0000000000..913a4adeea --- /dev/null +++ b/tests/components/apds9930/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + apds9930: !include common.yaml diff --git a/tests/components/apds9930/test.rp2040-ard.yaml b/tests/components/apds9930/test.rp2040-ard.yaml new file mode 100644 index 0000000000..be77d931df --- /dev/null +++ b/tests/components/apds9930/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + apds9930: !include common.yaml From b21354d6d9bd33803db17b0ffef48a141e1b19f4 Mon Sep 17 00:00:00 2001 From: Romain Luyten Date: Tue, 13 Oct 2026 01:22:34 +0200 Subject: [PATCH 232/268] [mistral_ir] Add climate component for Mistral MHFE1126A IR AC units (#17812) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/mistral_ir/__init__.py | 1 + esphome/components/mistral_ir/climate.py | 16 + esphome/components/mistral_ir/mistral_ir.cpp | 135 +++++++ esphome/components/mistral_ir/mistral_ir.h | 38 ++ tests/components/mistral_ir/__init__.py | 14 + tests/components/mistral_ir/benchmark.yaml | 4 + tests/components/mistral_ir/common.yaml | 4 + .../mistral_ir/mistral_ir_tests.cpp | 330 ++++++++++++++++++ .../components/mistral_ir/test.esp32-idf.yaml | 3 + .../mistral_ir/test.esp8266-ard.yaml | 3 + 11 files changed, 549 insertions(+) create mode 100644 esphome/components/mistral_ir/__init__.py create mode 100644 esphome/components/mistral_ir/climate.py create mode 100644 esphome/components/mistral_ir/mistral_ir.cpp create mode 100644 esphome/components/mistral_ir/mistral_ir.h create mode 100644 tests/components/mistral_ir/__init__.py create mode 100644 tests/components/mistral_ir/benchmark.yaml create mode 100644 tests/components/mistral_ir/common.yaml create mode 100644 tests/components/mistral_ir/mistral_ir_tests.cpp create mode 100644 tests/components/mistral_ir/test.esp32-idf.yaml create mode 100644 tests/components/mistral_ir/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 99a12f839f..f7875eb140 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -379,6 +379,7 @@ esphome/components/midea_ir/* @dudanov esphome/components/mipi_dsi/* @clydebarrow esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow +esphome/components/mistral_ir/* @lerhum esphome/components/mitsubishi/* @RubyBailey esphome/components/mitsubishi_cn105/* @crnjan esphome/components/mixer/speaker/* @kahrendt diff --git a/esphome/components/mistral_ir/__init__.py b/esphome/components/mistral_ir/__init__.py new file mode 100644 index 0000000000..deadd224d5 --- /dev/null +++ b/esphome/components/mistral_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mistral_ir" diff --git a/esphome/components/mistral_ir/climate.py b/esphome/components/mistral_ir/climate.py new file mode 100644 index 0000000000..cc152ca85d --- /dev/null +++ b/esphome/components/mistral_ir/climate.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@lerhum"] + +mistral_ir_ns = cg.esphome_ns.namespace("mistral_ir") +MistralIR = mistral_ir_ns.class_("MistralIR", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MistralIR) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("aeha") # used from C++ + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/mistral_ir/mistral_ir.cpp b/esphome/components/mistral_ir/mistral_ir.cpp new file mode 100644 index 0000000000..2eafcb348a --- /dev/null +++ b/esphome/components/mistral_ir/mistral_ir.cpp @@ -0,0 +1,135 @@ +#include "mistral_ir.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::mistral_ir { + +ESPHOME_LOG_TAG(TAG, "mistral_ir.climate"); + +// Fixed bytes of every frame; bytes 1, 3, 4, 5, 6 and 11 are filled in per state +static const uint8_t FRAME_TEMPLATE[MISTRAL_FRAME_SIZE] PROGMEM = {0x56, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xE8, 0x00, 0x60, 0x00}; +static const uint8_t FIXED_BYTES[] PROGMEM = {0, 2, 7, 8, 9, 10}; +static const uint8_t CHECKSUM_BYTES[] PROGMEM = {1, 3, 4, 5, 6, 8}; + +constexpr uint8_t MISTRAL_POWER_OFF = 0x00; +constexpr uint8_t MISTRAL_POWER_ON = 0x20; +constexpr uint8_t MISTRAL_TEMP_OFFSET = 0x9F; +constexpr uint8_t MISTRAL_MODE_AUTO = 0x10; + +// The tables live in flash, so entries are copied out before use +struct ModeCode { + climate::ClimateMode mode; + uint8_t code; +}; +static const ModeCode MODES[] PROGMEM = { + {climate::CLIMATE_MODE_COOL, 0xC0}, + {climate::CLIMATE_MODE_DRY, 0x40}, + {climate::CLIMATE_MODE_FAN_ONLY, 0x80}, + {climate::CLIMATE_MODE_HEAT, 0x60}, + {climate::CLIMATE_MODE_HEAT_COOL, MISTRAL_MODE_AUTO}, +}; + +struct FanCode { + climate::ClimateFanMode fan; + uint8_t byte1; + uint8_t byte6; +}; +static const FanCode FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, 0xA4, 0x00}, + {climate::CLIMATE_FAN_LOW, 0x44, 0x40}, + {climate::CLIMATE_FAN_MEDIUM, 0x24, 0xC0}, + {climate::CLIMATE_FAN_HIGH, 0xA4, 0xA0}, +}; + +template static T load_entry(const T &slot) { + T entry; + progmem_memcpy(&entry, &slot, sizeof(T)); + return entry; +} + +template static optional find_entry(const T (&table)[N], Pred pred) { + for (const T &slot : table) { + T entry = load_entry(slot); + if (pred(entry)) + return entry; + } + return {}; +} + +static uint8_t compute_checksum(const uint8_t *frame) { + uint8_t sum = 0; + for (const uint8_t &idx : CHECKSUM_BYTES) + sum += reverse_bits(frame[progmem_read_byte(&idx)]); + return reverse_bits(sum); +} + +static bool is_valid_frame(const uint8_t *frame) { + for (const uint8_t &slot : FIXED_BYTES) { + const uint8_t idx = progmem_read_byte(&slot); + if (frame[idx] != progmem_read_byte(&FRAME_TEMPLATE[idx])) + return false; + } + return frame[11] == compute_checksum(frame); +} + +void MistralIR::transmit_state() { + const auto mode = find_entry(MODES, [this](const ModeCode &m) { return m.mode == this->mode; }); + const climate::ClimateFanMode fan_mode = this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO); + const FanCode fan = + find_entry(FANS, [fan_mode](const FanCode &f) { return f.fan == fan_mode; }).value_or(load_entry(FANS[0])); + const uint8_t temp = lroundf(clamp(this->target_temperature, MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX)); + + uint8_t frame[MISTRAL_FRAME_SIZE]; + progmem_memcpy(frame, FRAME_TEMPLATE, sizeof(frame)); + frame[1] = fan.byte1; + frame[3] = this->mode == climate::CLIMATE_MODE_OFF ? MISTRAL_POWER_OFF : MISTRAL_POWER_ON; + frame[4] = mode.has_value() ? mode->code : MISTRAL_MODE_AUTO; + frame[5] = reverse_bits(static_cast(MISTRAL_TEMP_OFFSET - temp)); + frame[6] = fan.byte6; + frame[11] = compute_checksum(frame); + + auto transmit = this->transmitter_->transmit(); + remote_base::AEHAProtocol().encode(transmit.get_data(), MISTRAL_ADDRESS, frame, sizeof(frame)); + transmit.perform(); +} + +bool MistralIR::on_receive(remote_base::RemoteReceiveData data) { + auto aeha = remote_base::AEHAProtocol().decode(data); + if (!aeha.has_value() || aeha->address != MISTRAL_ADDRESS || aeha->data.size() != MISTRAL_FRAME_SIZE) + return false; + + const uint8_t *frame = aeha->data.data(); + if (!is_valid_frame(frame)) + return false; + + if (frame[3] == MISTRAL_POWER_OFF) { + this->mode = climate::CLIMATE_MODE_OFF; + this->publish_state(); + return true; + } + if (frame[3] != MISTRAL_POWER_ON) + return false; + + // Decode everything before touching the state so an unknown code leaves it untouched + const auto mode = find_entry(MODES, [frame](const ModeCode &m) { return m.code == frame[4]; }); + const auto fan = find_entry(FANS, [frame](const FanCode &f) { return f.byte1 == frame[1] && f.byte6 == frame[6]; }); + if (!mode.has_value() || !fan.has_value()) + return false; + + this->mode = mode->mode; + this->target_temperature = + clamp(MISTRAL_TEMP_OFFSET - reverse_bits(frame[5]), MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX); + this->fan_mode = fan->fan; + + ESP_LOGV(TAG, "Received Mistral frame: mode=%d temp=%.0f fan=%d", static_cast(this->mode), + this->target_temperature, static_cast(fan->fan)); + this->publish_state(); + return true; +} + +} // namespace esphome::mistral_ir diff --git a/esphome/components/mistral_ir/mistral_ir.h b/esphome/components/mistral_ir/mistral_ir.h new file mode 100644 index 0000000000..172af1d6a2 --- /dev/null +++ b/esphome/components/mistral_ir/mistral_ir.h @@ -0,0 +1,38 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::mistral_ir { + +// Temperature range, Celsius +constexpr uint8_t MISTRAL_TEMP_MIN = 16; +constexpr uint8_t MISTRAL_TEMP_MAX = 30; + +constexpr uint16_t MISTRAL_ADDRESS = 0x322C; +constexpr size_t MISTRAL_FRAME_SIZE = 12; + +/* Reverse-engineered protocol for a "Mistral" (Belgian brand, model MHFE1126A / MR112C) split AC unit + from the 2000-2010 era, likely a rebadged OEM design. It is carried over AEHA IR timing (address + 0x322C) but does not match any protocol already implemented in ESPHome, so it is decoded/encoded + directly here rather than through the generic AEHA helper. + + Frame layout (12 bytes): 56 xx 08 xx xx xx xx 00 E8 00 60 xx + byte[1] / byte[6] -- fan speed + byte[3] -- power: 0x20 on, 0x00 off + byte[4] -- mode + byte[5] -- target temperature (bit-reversed offset from 0x9F) + byte[11] -- checksum: bit-reverse the sum of bit-reversed bytes 1, 3, 4, 5, 6, 8 +*/ +class MistralIR : public climate_ir::ClimateIR { + public: + MistralIR() + : climate_ir::ClimateIR(MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}) {} + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; +}; + +} // namespace esphome::mistral_ir diff --git a/tests/components/mistral_ir/__init__.py b/tests/components/mistral_ir/__init__.py new file mode 100644 index 0000000000..c74d97e1d1 --- /dev/null +++ b/tests/components/mistral_ir/__init__.py @@ -0,0 +1,14 @@ +from esphome.components import remote_base +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # The platform's to_code never runs here; without this request remote_base drops aeha_protocol.cpp + remote_base.request_protocol("aeha") + + manifest.to_code = to_code_testing diff --git a/tests/components/mistral_ir/benchmark.yaml b/tests/components/mistral_ir/benchmark.yaml new file mode 100644 index 0000000000..ef3f9fb05d --- /dev/null +++ b/tests/components/mistral_ir/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The mistral_ir, climate_ir and remote_transmitter sources come in through +# the dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/mistral_ir/common.yaml b/tests/components/mistral_ir/common.yaml new file mode 100644 index 0000000000..43a2225bcb --- /dev/null +++ b/tests/components/mistral_ir/common.yaml @@ -0,0 +1,4 @@ +climate: + - platform: mistral_ir + name: Mistral IR + transmitter_id: xmitr diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp new file mode 100644 index 0000000000..37db86d10a --- /dev/null +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -0,0 +1,330 @@ +#include +#include +#include +#include +#include + +#include "esphome/components/mistral_ir/mistral_ir.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +namespace esphome::mistral_ir::testing { + +using Frame = std::vector; + +static constexpr uint8_t POWER_OFF = 0x00; +static constexpr uint8_t POWER_ON = 0x20; +static constexpr uint8_t MODE_COOL = 0xC0; +static constexpr uint8_t MODE_DRY = 0x40; +static constexpr uint8_t MODE_FAN_ONLY = 0x80; +static constexpr uint8_t MODE_HEAT = 0x60; +static constexpr uint8_t MODE_AUTO = 0x10; +static constexpr uint8_t FAN_AUTO_BYTE1 = 0xA4; +static constexpr uint8_t FAN_AUTO_BYTE6 = 0x00; +static constexpr uint8_t FAN_LOW_BYTE1 = 0x44; +static constexpr uint8_t FAN_LOW_BYTE6 = 0x40; +static constexpr uint8_t FAN_MEDIUM_BYTE1 = 0x24; +static constexpr uint8_t FAN_MEDIUM_BYTE6 = 0xC0; +static constexpr uint8_t FAN_HIGH_BYTE1 = 0xA4; +static constexpr uint8_t FAN_HIGH_BYTE6 = 0xA0; +static constexpr uint16_t WRONG_ADDRESS = 0x322D; +static constexpr uint32_t TOLERANCE_PERCENT = 25; + +static constexpr climate::ClimateMode ALL_MODES[] = { + climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_DRY, climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL, +}; +static constexpr climate::ClimateFanMode ALL_FANS[] = { + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, +}; + +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()); + } +}; + +static uint8_t checksum(const Frame &frame) { + uint8_t sum = 0; + for (size_t idx : {1, 3, 4, 5, 6, 8}) + sum += reverse_bits(frame[idx]); + return reverse_bits(sum); +} + +/// A received frame with a valid checksum, which callers may overwrite +static Frame make_frame(uint8_t byte1, uint8_t power, uint8_t mode, uint8_t temperature, uint8_t byte6) { + Frame frame{0x56, byte1, 0x08, power, mode, reverse_bits(static_cast(0x9F - temperature)), + byte6, 0x00, 0xE8, 0x00, 0x60, 0x00}; + frame[11] = checksum(frame); + return frame; +} + +static remote_base::RawTimings encode(uint16_t address, const Frame &frame) { + remote_base::RemoteTransmitData data; + remote_base::AEHAProtocol().encode(&data, address, frame.data(), frame.size()); + return data.get_data(); +} + +struct Fixture { + CapturingTransmitter transmitter; + MistralIR climate; + + Fixture() { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_mistral_ir"; + 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(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.perform(); + } + + /// The 12 data bytes of the last transmission, decoded by the AEHA protocol + Frame last_frame() const { + EXPECT_FALSE(this->transmitter.frames.empty()); + if (this->transmitter.frames.empty()) + return {}; + remote_base::RemoteReceiveData data(this->transmitter.frames.back(), TOLERANCE_PERCENT, + remote_base::TOLERANCE_MODE_PERCENTAGE); + const auto aeha = remote_base::AEHAProtocol().decode(data); + EXPECT_TRUE(aeha.has_value()); + if (!aeha.has_value()) + return {}; + EXPECT_EQ(aeha->address, MISTRAL_ADDRESS); + EXPECT_EQ(aeha->data.size(), MISTRAL_FRAME_SIZE); + return aeha->data; + } + + bool receive(const remote_base::RawTimings &raw) { + remote_base::RemoteReceiveData data(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(this->climate).on_receive(data); + } + + bool receive(const Frame &frame, uint16_t address = MISTRAL_ADDRESS) { return this->receive(encode(address, frame)); } + + void expect_state(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan) const { + EXPECT_EQ(this->climate.mode, mode); + EXPECT_FLOAT_EQ(this->climate.target_temperature, temperature); + EXPECT_EQ(this->climate.fan_mode, fan); + } +}; + +TEST(MistralIR, EncodesEachMode) { + struct Case { + climate::ClimateMode mode; + uint8_t code; + uint8_t checksum; + }; + // 22 C: 0x9F - 22 = 0x89, reversed 0x91. Checksum sums the reversed bytes 1, 3, 4, 5, 6 and 8: + // fan AUTO 0xA4 -> 0x25, power on 0x20 -> 0x04, mode, temperature 0x89, byte 6 0x00, 0xE8 -> 0x17 + const Case cases[] = { + // COOL 0xC0 -> 0x03: 0x25 + 0x04 + 0x03 + 0x89 + 0x00 + 0x17 = 0xCC, reversed 0x33 + {climate::CLIMATE_MODE_COOL, MODE_COOL, 0x33}, + // DRY 0x40 -> 0x02: sum 0xCB, reversed 0xD3 + {climate::CLIMATE_MODE_DRY, MODE_DRY, 0xD3}, + // FAN_ONLY 0x80 -> 0x01: sum 0xCA, reversed 0x53 + {climate::CLIMATE_MODE_FAN_ONLY, MODE_FAN_ONLY, 0x53}, + // HEAT 0x60 -> 0x06: sum 0xCF, reversed 0xF3 + {climate::CLIMATE_MODE_HEAT, MODE_HEAT, 0xF3}, + // HEAT_COOL 0x10 -> 0x08: sum 0xD1, reversed 0x8B + {climate::CLIMATE_MODE_HEAT_COOL, MODE_AUTO, 0x8B}, + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.mode)); + f.command(c.mode, 22.0f, climate::CLIMATE_FAN_AUTO); + const Frame expected{0x56, FAN_AUTO_BYTE1, 0x08, POWER_ON, c.code, 0x91, + FAN_AUTO_BYTE6, 0x00, 0xE8, 0x00, 0x60, c.checksum}; + EXPECT_EQ(f.last_frame(), expected); + } +} + +TEST(MistralIR, EncodesEachFanMode) { + struct Case { + climate::ClimateFanMode fan; + uint8_t byte1; + uint8_t byte6; + uint8_t checksum; + }; + // COOL at 24 C: 0x9F - 24 = 0x87, reversed 0xE1. Fixed part of the checksum sum: + // power on 0x04 + COOL 0x03 + temperature 0x87 + 0xE8 -> 0x17 = 0xA5, plus reversed bytes 1 and 6 + const Case cases[] = { + // AUTO 0xA4 -> 0x25, 0x00 -> 0x00: 0xA5 + 0x25 + 0x00 = 0xCA, reversed 0x53 + {climate::CLIMATE_FAN_AUTO, FAN_AUTO_BYTE1, FAN_AUTO_BYTE6, 0x53}, + // LOW 0x44 -> 0x22, 0x40 -> 0x02: 0xA5 + 0x22 + 0x02 = 0xC9, reversed 0x93 + {climate::CLIMATE_FAN_LOW, FAN_LOW_BYTE1, FAN_LOW_BYTE6, 0x93}, + // MEDIUM 0x24 -> 0x24, 0xC0 -> 0x03: 0xA5 + 0x24 + 0x03 = 0xCC, reversed 0x33 + {climate::CLIMATE_FAN_MEDIUM, FAN_MEDIUM_BYTE1, FAN_MEDIUM_BYTE6, 0x33}, + // HIGH 0xA4 -> 0x25, 0xA0 -> 0x05: 0xA5 + 0x25 + 0x05 = 0xCF, reversed 0xF3 + {climate::CLIMATE_FAN_HIGH, FAN_HIGH_BYTE1, FAN_HIGH_BYTE6, 0xF3}, + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.fan)); + f.command(climate::CLIMATE_MODE_COOL, 24.0f, c.fan); + const Frame frame = f.last_frame(); + ASSERT_EQ(frame.size(), MISTRAL_FRAME_SIZE); + EXPECT_EQ(frame[1], c.byte1); + EXPECT_EQ(frame[4], MODE_COOL); + EXPECT_EQ(frame[5], 0xE1); + EXPECT_EQ(frame[6], c.byte6); + EXPECT_EQ(frame[11], c.checksum); + } +} + +TEST(MistralIR, EncodesOff) { + Fixture f; + f.command(climate::CLIMATE_MODE_OFF, 22.0f, climate::CLIMATE_FAN_AUTO); + // OFF has no mode code, so byte 4 falls back to auto 0x10 -> 0x08 with power 0x00: + // 0x25 + 0x00 + 0x08 + 0x89 + 0x00 + 0x17 = 0xCD, reversed 0xB3 + const Frame expected{0x56, FAN_AUTO_BYTE1, 0x08, POWER_OFF, MODE_AUTO, 0x91, + FAN_AUTO_BYTE6, 0x00, 0xE8, 0x00, 0x60, 0xB3}; + EXPECT_EQ(f.last_frame(), expected); +} + +TEST(MistralIR, EncodesTemperatureRoundedAndClamped) { + struct Case { + float requested; + uint8_t byte5; + }; + const Case cases[] = { + {16.0f, 0xF1}, // 0x9F - 16 = 0x8F, reversed 0xF1 + {22.0f, 0x91}, // 0x9F - 22 = 0x89, reversed 0x91 + {30.0f, 0x81}, // 0x9F - 30 = 0x81, reversed 0x81 + {15.6f, 0xF1}, // rounds to 16 + {30.4f, 0x81}, // rounds to 30 + {10.0f, 0xF1}, // clamps to 16 + {35.0f, 0x81}, // clamps to 30 + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(c.requested); + f.command(climate::CLIMATE_MODE_COOL, c.requested, climate::CLIMATE_FAN_AUTO); + const Frame frame = f.last_frame(); + ASSERT_EQ(frame.size(), MISTRAL_FRAME_SIZE); + EXPECT_EQ(frame[5], c.byte5); + EXPECT_EQ(frame[11], checksum(frame)); + } +} + +TEST(MistralIR, RoundTripsEveryModeAndFan) { + for (climate::ClimateMode mode : ALL_MODES) { + for (climate::ClimateFanMode fan : ALL_FANS) { + for (float temperature : {16.0f, 23.0f, 30.0f}) { + SCOPED_TRACE(static_cast(mode) * 1000 + static_cast(fan) * 100 + static_cast(temperature)); + Fixture sender; + sender.command(mode, temperature, fan); + EXPECT_EQ(sender.last_frame().size(), MISTRAL_FRAME_SIZE); + Fixture receiver; + EXPECT_TRUE(receiver.receive(sender.transmitter.frames.back())); + receiver.expect_state(mode, temperature, fan); + } + } + } +} + +TEST(MistralIR, ReceivesHeatFrame) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_TRUE(f.receive(make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6))); + f.expect_state(climate::CLIMATE_MODE_HEAT, 25.0f, climate::CLIMATE_FAN_LOW); +} + +TEST(MistralIR, ReceivedPowerOffKeepsTemperatureAndFan) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_TRUE(f.receive(make_frame(FAN_AUTO_BYTE1, POWER_OFF, MODE_AUTO, 28, FAN_AUTO_BYTE6))); + f.expect_state(climate::CLIMATE_MODE_OFF, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsBadChecksum) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + frame[11] ^= 0x01; + EXPECT_FALSE(f.receive(frame)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsChangedFixedByte) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + for (size_t idx : {0, 2, 7, 8, 9, 10}) { + SCOPED_TRACE(idx); + Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + frame[idx] ^= 0x01; + // Byte 8 is part of the checksum, so keep it valid to test the fixed-byte check alone + frame[11] = checksum(frame); + EXPECT_FALSE(f.receive(frame)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + } +} + +TEST(MistralIR, RejectsUnknownPowerModeAndFan) { + struct Case { + const char *name; + uint8_t byte1; + uint8_t power; + uint8_t mode; + uint8_t byte6; + }; + const Case cases[] = { + {"power 0x10", FAN_LOW_BYTE1, 0x10, MODE_HEAT, FAN_LOW_BYTE6}, + {"mode 0x20", FAN_LOW_BYTE1, POWER_ON, 0x20, FAN_LOW_BYTE6}, + {"fan 0x44/0x00", FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, FAN_AUTO_BYTE6}, + {"fan 0x00/0x40", 0x00, POWER_ON, MODE_HEAT, FAN_LOW_BYTE6}, + }; + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + for (const Case &c : cases) { + SCOPED_TRACE(c.name); + EXPECT_FALSE(f.receive(make_frame(c.byte1, c.power, c.mode, 25, c.byte6))); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + } +} + +TEST(MistralIR, RejectsWrongAddress) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + const Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + EXPECT_FALSE(f.receive(frame, WRONG_ADDRESS)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsWrongFrameLength) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + const Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + Frame shorter(frame.begin(), frame.begin() + 11); + EXPECT_FALSE(f.receive(shorter)); + Frame longer = frame; + longer.push_back(0x00); + EXPECT_FALSE(f.receive(longer)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +} // namespace esphome::mistral_ir::testing diff --git a/tests/components/mistral_ir/test.esp32-idf.yaml b/tests/components/mistral_ir/test.esp32-idf.yaml new file mode 100644 index 0000000000..4f0186d9eb --- /dev/null +++ b/tests/components/mistral_ir/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + mistral_ir: !include common.yaml diff --git a/tests/components/mistral_ir/test.esp8266-ard.yaml b/tests/components/mistral_ir/test.esp8266-ard.yaml new file mode 100644 index 0000000000..3dddb49266 --- /dev/null +++ b/tests/components/mistral_ir/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + mistral_ir: !include common.yaml From 6c86a8903f1718a00e804d214cba783603f27a02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:28:17 -1000 Subject: [PATCH 233/268] [core] Report incomplete YAML discovery and record remote package sources (#20312) --- esphome/bundle.py | 12 +++ esphome/components/packages/__init__.py | 40 +++++++- esphome/yaml_util.py | 96 ++++++++++++++----- .../component_tests/packages/test_packages.py | 45 +++++++++ tests/unit_tests/test_bundle.py | 9 +- tests/unit_tests/test_yaml_util.py | 62 ++++++++++-- 6 files changed, 226 insertions(+), 38 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index 45d9a74b49..f499e0de1b 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -418,6 +418,18 @@ class ConfigBundleCreator: must ship every candidate so the remote build can pick any one. """ discovered = yaml_util.discover_user_yaml_files(self._config_path) + if discovered.load_errors: + _LOGGER.warning( + "Bundle may be incomplete; could not load all configuration files: %s", + "; ".join(discovered.load_errors), + ) + if discovered.unresolved: + _LOGGER.warning( + "Bundle may be incomplete; %d !include path(s) use " + "substitutions and cannot be captured: %s", + len(discovered.unresolved), + ", ".join(discovered.unresolved), + ) self._secrets_paths.update(discovered.secrets) # A !secret inside a file this re-parse does not reach (for example # a git-fetched package the builder re-fetches) still resolves diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 9498d4b4da..577385b217 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,5 +1,6 @@ from collections import UserDict from collections.abc import Callable +from dataclasses import dataclass, field from functools import reduce import logging from pathlib import Path @@ -35,11 +36,45 @@ from esphome.const import ( CONF_VARS, __version__ as ESPHOME_VERSION, ) -from esphome.core import EsphomeError +from esphome.core import CORE, EsphomeError _LOGGER = logging.getLogger(__name__) DOMAIN = "packages" + + +@dataclass(frozen=True) +class RemotePackageSource: + """A remote source a package was fetched from while processing the config.""" + + url: str + ref: str | None + + +@dataclass +class PackagesData: + """Per-run package state, keyed under DOMAIN in CORE.data.""" + + # Insertion-ordered and de-duplicated + remote_sources: dict[RemotePackageSource, None] = field(default_factory=dict) + + +def _get_data() -> PackagesData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = PackagesData() + return CORE.data[DOMAIN] + + +def get_remote_package_sources() -> tuple[RemotePackageSource, ...]: + """Remote sources fetched for this config, in fetch order. + + store_yaml uses them to tell remote parts of the config from local files. + """ + if (data := CORE.data.get(DOMAIN)) is None: + return () + return tuple(data.remote_sources) + + # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -193,6 +228,9 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), ) + _get_data().remote_sources[ + RemotePackageSource(config[CONF_URL], config.get(CONF_REF)) + ] = None files: list[dict[str, Any]] = [] # ``repo_root`` is the directory containing ``.git`` and must be passed diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 5882c133f6..f02819a3ed 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -12,7 +12,7 @@ import math import os from pathlib import Path import re -from typing import Any +from typing import Any, NamedTuple import uuid import yaml @@ -410,9 +410,11 @@ def _load_include_candidates( include: IncludeFile, *, warn_on_unresolved: bool, + warn_on_load_error: bool, seen: set[int], expanded_paths: set[Path], keepalive: list[Any], + result: ForceLoadResult, ) -> None: """Load every filesystem candidate for an unresolved ``IncludeFile``.""" from voluptuous import Invalid @@ -425,6 +427,7 @@ def _load_include_candidates( include.file, include.parent_file, ) + result.unresolved.append(str(include.file)) return _LOGGER.debug( "Expanding !include %s (referenced from %s) to %d candidate file(s)", @@ -439,27 +442,27 @@ def _load_include_candidates( try: loaded = include.with_file(candidate.as_posix()).load() except (EsphomeError, Invalid) as err: - # Unlike an unresolved pattern (expected during the discovery - # re-parse), a matched on-disk candidate that fails to load is a - # genuine user error; warn in every mode. The file itself is - # still tracked (the load listener fires before parsing), only - # its nested includes go undiscovered. - _LOGGER.warning( + # A real user error. The file stays tracked (the listener fires + # before parsing); only its nested includes are missed. + (_LOGGER.warning if warn_on_load_error else _LOGGER.debug)( "Failed to load candidate %s for !include %s: %s", candidate, include.file, err, ) + result.errors.append(f"{candidate}: {err}") continue # The throwaway IncludeFile is this tree's only owner; keep the tree # alive so ids recorded in ``seen`` stay unique for the traversal. keepalive.append(loaded) - force_load_include_files( + _force_load_include_files( loaded, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=seen, _expanded_paths=expanded_paths, _keepalive=keepalive, + _result=result, ) @@ -473,14 +476,19 @@ def find_secret_references(text: str) -> set[str]: return {match.group(1) for match in _SECRET_REFERENCE_RE.finditer(text)} +class ForceLoadResult(NamedTuple): + """Templated includes that matched no file, and includes that failed to load. + + Either being non-empty means the walk was incomplete. + """ + + unresolved: list[str] + errors: list[str] + + def force_load_include_files( - obj: Any, - *, - warn_on_unresolved: bool = True, - _seen: set[int] | None = None, - _expanded_paths: set[Path] | None = None, - _keepalive: list[Any] | None = None, -) -> None: + obj: Any, *, warn_on_unresolved: bool = True, warn_on_load_error: bool = True +) -> ForceLoadResult: """Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree. Nested ``!include`` returns a deferred ``IncludeFile`` that is only resolved @@ -495,8 +503,30 @@ def force_load_include_files( expression could select. By default a warning is logged when no candidate exists; pass ``warn_on_unresolved=False`` (used by discovery paths that run on a fresh re-parse where substitutions haven't been applied yet) to - demote it to a debug log. + demote it to a debug log. Callers that report ``errors`` themselves pass + ``warn_on_load_error=False`` so load failures are not logged twice. """ + result = ForceLoadResult([], []) + _force_load_include_files( + obj, + warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, + _result=result, + ) + return result + + +def _force_load_include_files( + obj: Any, + *, + warn_on_unresolved: bool = True, + warn_on_load_error: bool = True, + _seen: set[int] | None = None, + _expanded_paths: set[Path] | None = None, + _keepalive: list[Any] | None = None, + _result: ForceLoadResult, +) -> None: + """Walk for :func:`force_load_include_files`, adding to ``result``.""" from voluptuous import Invalid if _seen is None: @@ -519,51 +549,60 @@ def force_load_include_files( _load_include_candidates( obj, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, seen=_seen, expanded_paths=_expanded_paths, keepalive=_keepalive, + result=_result, ) return try: loaded = obj.load() except (EsphomeError, Invalid) as err: - _LOGGER.warning( + (_LOGGER.warning if warn_on_load_error else _LOGGER.debug)( "Failed to load !include %s (referenced from %s): %s", obj.file, obj.parent_file, err, ) + _result.errors.append(f"{obj.file}: {err}") return - force_load_include_files( + _force_load_include_files( loaded, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) elif isinstance(obj, dict): if id(obj) in _seen: return _seen.add(id(obj)) for value in obj.values(): - force_load_include_files( + _force_load_include_files( value, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) elif isinstance(obj, (list, tuple)): if id(obj) in _seen: return _seen.add(id(obj)) for item in obj: - force_load_include_files( + _force_load_include_files( item, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) @@ -575,11 +614,14 @@ class DiscoveredYamlFiles: were re-parsing the user's config; ``secrets`` is the subset whose *un-resolved* filename matched :data:`esphome.const.SECRETS_FILES` (so a ``secrets.yaml`` symlinked to a differently-named target is still - flagged as secrets). + flagged as secrets). ``files`` is incomplete when ``unresolved`` (templated + include paths) or ``load_errors`` is non-empty. """ files: list[Path] = field(default_factory=list) secrets: set[Path] = field(default_factory=set) + unresolved: list[str] = field(default_factory=list) + load_errors: list[str] = field(default_factory=list) def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: @@ -609,9 +651,13 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: try: try: data = load_yaml(config_path) - except EsphomeError: - return DiscoveredYamlFiles(list(loaded), secrets) - force_load_include_files(data, warn_on_unresolved=False) + except EsphomeError as err: + return DiscoveredYamlFiles( + list(loaded), secrets, load_errors=[f"{config_path}: {err}"] + ) + unresolved, load_errors = force_load_include_files( + data, warn_on_unresolved=False, warn_on_load_error=False + ) finally: _load_listeners.remove(_capture_secret) @@ -622,7 +668,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: if path not in seen: seen.add(path) unique.append(path) - return DiscoveredYamlFiles(unique, secrets) + return DiscoveredYamlFiles(unique, secrets, unresolved, load_errors) def _add_data_ref(fn): diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 418cab5ea1..fd7e9f1278 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -10,9 +10,11 @@ import pytest from esphome import bundle from esphome.components.packages import ( CONFIG_SCHEMA, + RemotePackageSource, _substitute_package_definition, _walk_packages, do_packages_pass, + get_remote_package_sources, merge_packages, resolve_packages, ) @@ -614,6 +616,49 @@ def test_package_remove_by_missing_id() -> None: assert actual == expected +def test_get_remote_package_sources_empty_without_remote_packages() -> None: + """A config without remote packages records no sources.""" + assert get_remote_package_sources() == () + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_get_remote_package_sources_records_each_fetch_once( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """Every fetched remote source is recorded exactly once.""" + mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock()) + mock_is_file.return_value = True + mock_load_yaml.side_effect = lambda *args, **kwargs: OrderedDict() + + config = { + CONF_PACKAGES: { + "first": { + CONF_URL: "https://github.com/esphome/repo-a", + CONF_REF: "main", + CONF_FILES: [TEST_YAML_FILENAME], + }, + "again": { + CONF_URL: "https://github.com/esphome/repo-a", + CONF_REF: "main", + CONF_FILES: ["other.yaml"], + }, + "second": { + CONF_URL: "https://github.com/esphome/repo-b", + CONF_FILES: [TEST_YAML_FILENAME], + }, + } + } + packages_pass(config) + + # packages are processed last to first, so repo-b is fetched before repo-a + assert get_remote_package_sources() == ( + RemotePackageSource("https://github.com/esphome/repo-b", None), + RemotePackageSource("https://github.com/esphome/repo-a", "main"), + ) + + @patch("esphome.yaml_util.load_yaml") @patch("pathlib.Path.is_file") @patch("esphome.git.clone_or_update") diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 6a08676784..fe4bf19715 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -1363,7 +1363,8 @@ def test_discover_files_candidate_outside_config_dir_skipped( def test_discover_files_nested_include_load_failure( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """A nested !include pointing at a missing file is logged and skipped.""" + """A nested !include pointing at a missing file is reported in the bundle + summary, not a second time by yaml_util, and skipped.""" config_dir = _setup_config_dir(tmp_path) (config_dir / "test.yaml").write_text( "esphome:\n name: test\nwifi: !include missing.yaml\n" @@ -1374,10 +1375,12 @@ def test_discover_files_nested_include_load_failure( paths = [f.path for f in files] assert "test.yaml" in paths + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] assert any( - "failed to load !include" in r.message.lower() and "missing.yaml" in r.message - for r in caplog.records + m.startswith("Bundle may be incomplete") and "missing.yaml" in m + for m in warnings ) + assert not any("failed to load !include" in m.lower() for m in warnings) def test_force_load_skips_duplicate_include_file() -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 24a431b0c0..c30b03467f 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1280,22 +1280,52 @@ def test_force_load_include_files_unresolved_log_level( assert matching == [expect_level] +def test_force_load_include_files_returns_unresolved_paths( + patch_include_file: None, +) -> None: + """Includes with substitution-templated paths are reported back to the + caller; resolvable ones are not.""" + templated = _StubInclude("${var}.yaml", unresolved=True) + plain = _StubInclude("ok.yaml") + result = force_load_include_files({"a": templated, "b": plain}) + assert result.unresolved == [str(templated.file)] + assert result.errors == [] + assert plain.load_calls == 1 + + def test_force_load_include_files_warns_on_load_failure( patch_include_file: None, caplog: pytest.LogCaptureFixture, ) -> None: - """An `EsphomeError` raised by `load()` is caught and logged, not propagated.""" + """An `EsphomeError` raised by `load()` is caught, logged, and reported to + the caller — not propagated.""" stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) with caplog.at_level("WARNING", logger="esphome.yaml_util"): - force_load_include_files({"k": stub}) + result = force_load_include_files({"k": stub}) assert any( "Failed to load !include" in r.message and "missing.yaml" in r.message for r in caplog.records ) + assert result.errors == [f"{stub.file}: boom"] + assert result.unresolved == [] + + +def test_force_load_include_files_load_failure_quiet_when_caller_reports( + patch_include_file: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """With warn_on_load_error=False the failure is still returned but only + logged at debug, so a caller that reports it does not duplicate it.""" + stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + result = force_load_include_files({"k": stub}, warn_on_load_error=False) + assert result.errors == [f"{stub.file}: boom"] + failures = [r for r in caplog.records if "Failed to load !include" in r.message] + assert [r.levelname for r in failures] == ["DEBUG"] def test_discovered_yaml_files_holds_files_and_secrets() -> None: - """`DiscoveredYamlFiles` is a small data carrier; both fields are mandatory.""" + """`DiscoveredYamlFiles` is a small data carrier.""" files = [Path("/tmp/a.yaml")] secrets = {Path("/tmp/a.yaml")} discovered = DiscoveredYamlFiles(files, secrets) @@ -1358,11 +1388,24 @@ def test_discover_user_yaml_files_flags_secrets_symlink(tmp_path: Path) -> None: assert target.resolve() in discovered.secrets -def test_discover_user_yaml_files_swallows_parse_errors(tmp_path: Path) -> None: - """A YAML parse failure returns whatever was tracked so far without raising.""" +def test_discover_user_yaml_files_reports_parse_errors(tmp_path: Path) -> None: + """A YAML parse failure is surfaced in `.load_errors` (not raised), so + consumers can tell the file set is incomplete.""" entry = _write(tmp_path, "entry.yaml", "esphome: [unterminated\n") discovered = discover_user_yaml_files(entry) assert isinstance(discovered, DiscoveredYamlFiles) + assert len(discovered.load_errors) == 1 + assert "entry.yaml" in discovered.load_errors[0] + + +def test_discover_user_yaml_files_reports_unresolved_includes( + tmp_path: Path, +) -> None: + """A substitution-templated `!include` path is surfaced in `.unresolved`.""" + entry = _write_entry_including(tmp_path, "${board}.yaml") + discovered = discover_user_yaml_files(entry) + assert len(discovered.unresolved) == 1 + assert "${board}.yaml" in discovered.unresolved[0] def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None: @@ -1589,9 +1632,9 @@ def test_discover_user_yaml_files_many_candidates_keep_nested_includes( def test_discover_user_yaml_files_bad_candidate_still_tracked( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """A matched candidate that fails to parse warns even during discovery, - stays tracked (the load listener fires before parsing), and doesn't block - other candidates.""" + """A matched candidate that fails to parse is reported in ``load_errors`` + (logged only at debug, the caller reports it), stays tracked (the load + listener fires before parsing), and doesn't block other candidates.""" _write(tmp_path, "keys/good.yaml", "api:\n") _write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n") with caplog.at_level("DEBUG", logger="esphome.yaml_util"): @@ -1604,7 +1647,8 @@ def test_discover_user_yaml_files_bad_candidate_still_tracked( matching = [ r.levelname for r in caplog.records if "Failed to load candidate" in r.message ] - assert matching == ["WARNING"] + assert matching == ["DEBUG"] + assert any("bad.yaml" in e for e in discovered.load_errors) def test_discover_user_yaml_files_tolerates_templated_top_level_include( From cce435a31ed5172660aa61343d957cbab545393c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:34:44 -1000 Subject: [PATCH 234/268] [gpio] Keep each interlock group in one shared flash table (#20238) --- esphome/components/gpio/switch/__init__.py | 18 ++++++---- .../components/gpio/switch/gpio_switch.cpp | 4 --- esphome/components/gpio/switch/gpio_switch.h | 7 ++-- .../component_tests/gpio/test_gpio_switch.py | 33 +++++++++++++++++++ .../gpio/test_gpio_switch_interlock.yaml | 23 +++++++++++++ 5 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/gpio/test_gpio_switch.py create mode 100644 tests/component_tests/gpio/test_gpio_switch_interlock.yaml diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 766cdc4afb..481b8d01da 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -36,11 +36,17 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - if CONF_INTERLOCK in config: + if (interlock := config.get(CONF_INTERLOCK)) is not None: cg.add_define("USE_GPIO_SWITCH_INTERLOCK") - interlock = [] - for it in config[CONF_INTERLOCK]: - lock = await cg.get_variable(it) - interlock.append(lock) - cg.add(var.set_interlock(interlock)) + locks = [await cg.get_variable(it) for it in interlock] + # The group's table lists this switch too, so every member shares one table. + group = sorted({str(s): s for s in (var, *locks)}.values(), key=str) + if len(group) > 1: + table = cg.shared_progmem_array( + "gpio_interlock", + switch.Switch.operator("ptr"), + cg.ArrayInitializer(*group), + constexpr=False, + ) + cg.add(var.set_interlock(table, len(group))) cg.add(var.set_interlock_wait_time(config[CONF_INTERLOCK_WAIT_TIME])) diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index 450a97eb19..68086d9466 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -67,8 +67,4 @@ void GPIOSwitch::write_state(bool state) { this->publish_state(state); } -#ifdef USE_GPIO_SWITCH_INTERLOCK -void GPIOSwitch::set_interlock(const std::initializer_list &interlock) { this->interlock_ = interlock; } -#endif - } // namespace esphome::gpio diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index e7323e6e93..64e4f95f16 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -21,7 +21,10 @@ class GPIOSwitch final : public switch_::Switch, public Component { void setup() override; void dump_config() override; #ifdef USE_GPIO_SWITCH_INTERLOCK - void set_interlock(const std::initializer_list &interlock); + /// Codegen only: the table must outlive this switch; it may list this switch, which is skipped. + void set_interlock(Switch *const *interlock, size_t count) { + this->interlock_ = ConstVector(interlock, count); + } void set_interlock_wait_time(uint32_t interlock_wait_time) { interlock_wait_time_ = interlock_wait_time; } #endif @@ -30,7 +33,7 @@ class GPIOSwitch final : public switch_::Switch, public Component { GPIOPin *pin_{nullptr}; #ifdef USE_GPIO_SWITCH_INTERLOCK - FixedVector interlock_; + ConstVector interlock_; uint32_t interlock_wait_time_{0}; #endif }; diff --git a/tests/component_tests/gpio/test_gpio_switch.py b/tests/component_tests/gpio/test_gpio_switch.py new file mode 100644 index 0000000000..e62f11fd09 --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_switch.py @@ -0,0 +1,33 @@ +"""Tests for the GPIO switch component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_gpio_switch_interlock_group_shares_one_table( + generate_main: Callable[[str | Path], str], +) -> None: + """Every member of an interlock group points at the same flash table.""" + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_switch_interlock.yaml" + ) + + tables = re.findall(r"(gpio_interlock\w*)\[\] PROGMEM = \{([^}]*)\};", main_cpp) + assert tables == [("gpio_interlock", "sw_a, sw_b, sw_c")] + for sw in ("sw_a", "sw_b", "sw_c"): + assert f"{sw}->set_interlock(gpio_interlock, 3);" in main_cpp + + +def test_gpio_switch_interlock_with_only_itself_has_no_table( + generate_main: Callable[[str | Path], str], +) -> None: + """A switch interlocked only with itself gets no table and no set_interlock.""" + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_switch_interlock.yaml" + ) + + assert "sw_self->set_interlock(" not in main_cpp + assert "sw_self->set_interlock_wait_time(0);" in main_cpp diff --git a/tests/component_tests/gpio/test_gpio_switch_interlock.yaml b/tests/component_tests/gpio/test_gpio_switch_interlock.yaml new file mode 100644 index 0000000000..a3da64388f --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_switch_interlock.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + board: esp32dev + +switch: + - platform: gpio + pin: 12 + id: sw_a + interlock: [sw_b, sw_c] + - platform: gpio + pin: 13 + id: sw_b + interlock: [sw_a, sw_c] + - platform: gpio + pin: 14 + id: sw_c + interlock: [sw_a, sw_b] + - platform: gpio + pin: 15 + id: sw_self + interlock: [sw_self] From 72ca565a0e4e2a676748b202caef348332d88a14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:35:03 -1000 Subject: [PATCH 235/268] [core] Resume if and while from the end of their branch instead of an embedded continuation action (#20283) --- esphome/core/automation.h | 61 ++- esphome/core/base_automation.h | 100 ++--- tests/components/api/common-base.yaml | 8 +- .../core/test_action_branch_owner.cpp | 403 ++++++++++++++++++ .../fixtures/branch_resume_async.yaml | 28 ++ .../fixtures/continuation_actions.yaml | 14 +- tests/integration/test_branch_resume_async.py | 14 + .../integration/test_continuation_actions.py | 9 +- 8 files changed, 533 insertions(+), 104 deletions(-) create mode 100644 tests/components/core/test_action_branch_owner.cpp diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 35c2a17c24..bbb7f08fb8 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -576,20 +576,22 @@ template class Action { /// the following actions in the chain. int num_running_total() { int total = this->num_running_; - if (this->next_ != nullptr) - total += this->next_->num_running_total(); + if (Action *next = this->chain_next_()) + total += next->num_running_total(); return total; } protected: friend ActionList; - template friend class ContinuationAction; + /// For a branch owner (if, while), play() runs when the branch finishes. virtual void play(const Ts &...x) = 0; void play_next_(const Ts &...x) { if (this->num_running_ > 0) { this->num_running_--; - if (this->next_ != nullptr) { + if (this->next_is_owner_()) { + this->owner_()->play(x...); + } else if (this->next_ != nullptr) { this->next_->play_complex(x...); } } @@ -603,15 +605,24 @@ template class Action { virtual void stop() {} void stop_next_() { - if (this->next_ != nullptr) { - this->next_->stop_complex(); - } + if (Action *next = this->chain_next_()) + next->stop_complex(); } bool is_running_next_() { - if (this->next_ == nullptr) - return false; - return this->next_->is_running(); + Action *next = this->chain_next_(); + return next != nullptr && next->is_running(); + } + + // A branch's last action points at its owner with the low bit set. Only Action objects are + // tagged, never function pointers, which use that bit on ARM Thumb (#9283). + static constexpr uintptr_t OWNER_TAG = 1; + bool next_is_owner_() const { return reinterpret_cast(this->next_) & OWNER_TAG; } + /// Only valid when next_is_owner_(). + Action *owner_() const { return reinterpret_cast(reinterpret_cast(this->next_) - OWNER_TAG); } + Action *chain_next_() const { return this->next_is_owner_() ? nullptr : this->next_; } + static Action *tag_owner(Action *owner) { + return reinterpret_cast(reinterpret_cast(owner) + OWNER_TAG); } Action *next_{nullptr}; @@ -621,25 +632,30 @@ template class Action { int num_running_{0}; }; +// The vtable pointer gives every instantiation this alignment. +static_assert(alignof(Action<>) >= 2, "the owner tag needs a free low bit in Action object pointers"); + template class ActionList { public: void add_action(Action *action) { // Walk to end of chain - action lists are short and only built during setup() - Action **tail = &this->actions_; - while (*tail != nullptr) - tail = &(*tail)->next_; - *tail = action; + *this->tail_() = action; } void add_actions(const std::initializer_list *> &actions) { // Find tail once, then append all actions in a single pass - Action **tail = &this->actions_; - while (*tail != nullptr) - tail = &(*tail)->next_; + Action **tail = this->tail_(); for (auto *action : actions) { *tail = action; tail = &action->next_; } } + /// Resume `owner` via play() when the list finishes; call once, after adding actions. + void set_owner(Action *owner) { + // Nothing to tag in an empty list + if (this->actions_ == nullptr) + return; + *this->tail_() = Action::tag_owner(owner); + } // Force-inline: part of the Trigger→Automation→ActionList forwarding // chain collapsed to reduce automation call stack depth. inline void play(const Ts &...x) ESPHOME_ALWAYS_INLINE { @@ -672,6 +688,17 @@ template class ActionList { template void play_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play(std::get(tuple)...); } + // Lists with an owner are complete; never append after set_owner() + Action **tail_() { + Action **tail = &this->actions_; + while (*tail != nullptr) { + // Not checked in release builds: only automation.py builds these lists, appending each action and + // then finishing the branch once, so appending after set_owner() would be a codegen bug. + ESPHOME_DEBUG_ASSERT(!(*tail)->next_is_owner_()); + tail = &(*tail)->next_; + } + return tail; + } Action *actions_{nullptr}; }; diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 09cde1261d..c59e27f43a 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -278,70 +278,33 @@ template class ApplyCondition final : public Condition< bool check(const Ts &...x) override { return Fn(x...); } }; -/// Simple continuation action that calls play_next_ on a parent action. -/// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). -template class ContinuationAction : public Action { - public: - explicit ContinuationAction(Action *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->play_next_(x...); } - - protected: - Action *parent_; -}; - -// Forward declaration for WhileLoopContinuation -template class WhileAction; - -/// Loop continuation for WhileAction that checks condition and repeats or continues. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). -template class WhileLoopContinuation : public Action { - public: - explicit WhileLoopContinuation(WhileAction *parent) : parent_(parent) {} - - void play(const Ts &...x) override; - - protected: - WhileAction *parent_; -}; - -// Wraps a ContinuationAction when Enabled, empty otherwise. -// Lets IfAction elide the else continuation when HasElse is false. -template struct OptionalContinuation { - ContinuationAction action; - explicit OptionalContinuation(Action *parent) : action(parent) {} -}; -template struct OptionalContinuation { - explicit OptionalContinuation(Action * /*parent*/) {} -}; - template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} // 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. + // set_owner() closes the list; appending after it is a codegen bug. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(&this->then_continuation_); + this->then_.set_owner(this); } void add_else(const std::initializer_list *> &actions) requires(HasElse) { this->else_.add_actions(actions); - this->else_.add_action(&this->else_continuation_.action); + this->else_.set_owner(this); } // 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 finish_then() { this->then_.set_owner(this); } 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 finish_else() requires(HasElse) { this->else_.set_owner(this); } - void play_complex(const Ts &...x) override { + void play_complex(const Ts &...x) final { this->num_running_++; + // The condition may stop this automation (e.g. script.stop in a lambda) if (this->condition_->check(x...)) { if (!this->then_.empty() && this->num_running_ > 0) { this->then_.play(x...); @@ -356,8 +319,8 @@ template class IfAction : public Action { this->play_next_(x...); } - void play(const Ts &...x) override { /* ignore - see play_complex */ - } + // Branch-end resume hook + void play(const Ts &...x) override { this->play_next_(x...); } void stop() override { this->then_.stop(); @@ -369,10 +332,8 @@ template class IfAction : public Action { protected: Condition *condition_; ActionList then_; - ContinuationAction then_continuation_{this}; struct NoElse {}; [[no_unique_address]] std::conditional_t, NoElse> else_; - [[no_unique_address]] OptionalContinuation else_continuation_{this}; }; template class WhileAction : public Action { @@ -383,14 +344,12 @@ template class WhileAction : public Action { // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(&this->loop_continuation_); + this->then_.set_owner(this); } void add_then_action(Action *action) { this->then_.add_action(action); } - void finish_then() { this->then_.add_action(&this->loop_continuation_); } + void finish_then() { this->then_.set_owner(this); } - friend class WhileLoopContinuation; - - void play_complex(const Ts &...x) override { + void play_complex(const Ts &...x) final { this->num_running_++; // Initial condition check if (!this->condition_->check(x...)) { @@ -400,12 +359,25 @@ template class WhileAction : public Action { return; } - if (this->num_running_ > 0) { - this->then_.play(x...); + // The condition may have stopped this automation + if (this->num_running_ == 0) + return; + // An empty body cannot resume the loop + if (this->then_.empty()) { + this->play_next_(x...); + return; } + this->then_.play(x...); } - void play(const Ts &...x) override { /* ignore - see play_complex */ + // Branch-end resume hook: loop again or continue + void play(const Ts &...x) override { + // The condition may stop this automation + if (this->num_running_ > 0 && this->condition_->check(x...) && this->num_running_ > 0) { + this->then_.play(x...); + } else { + this->play_next_(x...); + } } void stop() override { this->then_.stop(); } @@ -413,25 +385,13 @@ template class WhileAction : public Action { protected: Condition *condition_; ActionList then_; - WhileLoopContinuation loop_continuation_{this}; }; -// Implementation of WhileLoopContinuation::play -template void WhileLoopContinuation::play(const Ts &...x) { - if (this->parent_->num_running_ > 0 && this->parent_->condition_->check(x...)) { - // play again - this->parent_->then_.play(x...); - } else { - // condition false, play next - this->parent_->play_next_(x...); - } -} - // Forward declaration for RepeatLoopContinuation template class RepeatAction; /// Loop continuation for RepeatAction that increments iteration and repeats or continues. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). +/// Repeat keeps a continuation: its body takes an extra uint32_t, so it cannot be tagged. template class RepeatLoopContinuation : public Action { public: explicit RepeatLoopContinuation(RepeatAction *parent) : parent_(parent) {} @@ -447,7 +407,7 @@ template class RepeatAction : public Action { TEMPLATABLE_VALUE(uint32_t, count) // Precondition: use either add_then or add_then_action plus one finish_then, never both - // (see IfAction::add_then). + // (see IfAction::add_then); appending the continuation twice forms a self-loop. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 1513d537c2..7f6cb83f16 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -181,7 +181,7 @@ api: room: !lambda 'return room;' ratio: !lambda 'return level / 100.0f;' - homeassistant.tag_scanned: !lambda 'return room;' - # Test ContinuationAction (IfAction with then/else branches) + # Test IfAction with then/else branches - action: test_if_action variables: condition: bool @@ -199,7 +199,7 @@ api: format: "Condition false, value: %ld" args: ['(long) value'] - logger.log: "After if/else" - # Test nested IfAction (multiple ContinuationAction instances) + # Test nested IfAction - action: test_nested_if variables: outer: bool @@ -219,7 +219,7 @@ api: else: - logger.log: "Outer false" - logger.log: "After nested if" - # Test WhileLoopContinuation (WhileAction) + # Test WhileAction - action: test_while_action variables: max_count: int @@ -234,7 +234,7 @@ api: args: ['id(api_continuation_test_counter)'] - lambda: 'id(api_continuation_test_counter)++;' - logger.log: "After while loop" - # Test RepeatLoopContinuation (RepeatAction) + # Test RepeatAction - action: test_repeat_action variables: count: int diff --git a/tests/components/core/test_action_branch_owner.cpp b/tests/components/core/test_action_branch_owner.cpp new file mode 100644 index 0000000000..126f932686 --- /dev/null +++ b/tests/components/core/test_action_branch_owner.cpp @@ -0,0 +1,403 @@ +#include +#include + +#include "esphome/core/automation.h" +#include "esphome/core/base_automation.h" + +namespace esphome::core::testing { + +using Log = std::vector; + +// Records its id when it runs, then continues the chain +class RecordAction : public Action<> { + public: + RecordAction(Log *log, int id) : log_(log), id_(id) {} + void play() override { this->log_->push_back(this->id_); } + Action<> *next() const { return this->next_; } + + protected: + Log *log_; + int id_; +}; + +// Holds the chain until finish() is called, like a delay +class DeferredAction : public Action<> { + public: + explicit DeferredAction(Log *log, int id) : log_(log), id_(id) {} + void play_complex() override { + this->num_running_++; + this->log_->push_back(this->id_); + this->pending_ = true; + } + void play() override {} + void stop() override { this->pending_ = false; } + void finish() { + if (this->pending_) { + this->pending_ = false; + this->play_next_(); + } + } + bool pending() const { return this->pending_; } + + protected: + Log *log_; + int id_; + bool pending_{false}; +}; + +class FlagCondition : public Condition<> { + public: + bool check() override { return this->value; } + bool value{true}; +}; + +// Stops the given list during check(), like script.stop in a lambda condition +class StoppingCondition : public Condition<> { + public: + bool check() override { + if (this->list != nullptr) + this->list->stop(); + return true; + } + ActionList<> *list{nullptr}; +}; + +// Stops the given list on the Nth check and keeps returning true +class StopOnCheckCondition : public Condition<> { + public: + explicit StopOnCheckCondition(int stop_on) : stop_on_(stop_on) {} + bool check() override { + if (++this->checks_ == this->stop_on_ && this->list != nullptr) + this->list->stop(); + return true; + } + ActionList<> *list{nullptr}; + + protected: + int stop_on_; + int checks_{0}; +}; + +// Turns its condition off after a number of checks +class CountdownCondition : public Condition<> { + public: + explicit CountdownCondition(int passes) : passes_(passes) {} + bool check() override { return this->passes_-- > 0; } + + protected: + int passes_; +}; + +// Reads an action's next_ through Action's own tag helpers +class TaggedOwner : public RecordAction { + public: + using RecordAction::RecordAction; + static bool is_tagged(const TaggedOwner *a) { return a->next_is_owner_(); } + static Action<> *owner_of(const TaggedOwner *a) { return a->owner_(); } +}; + +class InspectList : public ActionList<> { + public: + Action<> *head() const { return this->actions_; } +}; + +TEST(ActionBranchOwner, SetOwnerTagsOnlyTheLastAction) { + Log log; + TaggedOwner a(&log, 1), b(&log, 2), c(&log, 3); + TaggedOwner owner(&log, 99); + InspectList list; + list.add_actions({&a, &b}); + list.add_action(&c); // appending before set_owner keeps order + list.set_owner(&owner); + EXPECT_EQ(list.head(), &a); + EXPECT_EQ(a.next(), &b); + EXPECT_EQ(b.next(), &c); + EXPECT_FALSE(TaggedOwner::is_tagged(&a)); + EXPECT_FALSE(TaggedOwner::is_tagged(&b)); + EXPECT_TRUE(TaggedOwner::is_tagged(&c)); + EXPECT_EQ(TaggedOwner::owner_of(&c), &owner); +} + +TEST(ActionBranchOwner, EmptyListStaysEmpty) { + Log log; + TaggedOwner owner(&log, 99); + InspectList list; + list.set_owner(&owner); + EXPECT_EQ(list.head(), nullptr); + EXPECT_TRUE(list.empty()); +} + +// ESPHOME_DEBUG_ASSERT is only live because script/cpp_unit_test.py builds with -DESPHOME_DEBUG +TEST(ActionBranchOwnerDeathTest, AppendAfterSetOwnerAsserts) { + Log log; + RecordAction a(&log, 1), late(&log, 2); + TaggedOwner owner(&log, 99); + ActionList<> list; + list.add_action(&a); + list.set_owner(&owner); + EXPECT_DEATH(list.add_action(&late), ""); +} + +TEST(ActionBranchOwner, IfThenResumesAfterTheIfOnce) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction t1(&log, 1), t2(&log, 2), e1(&log, 3), after(&log, 4); + if_action.add_then({&t1, &t2}); + if_action.add_else({&e1}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1, 2, 4})); + log.clear(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{3, 4})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +// The per-item calls automation.py emits +TEST(ActionBranchOwner, PerItemIfAndWhileResumeAfterTheirBranch) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction t1(&log, 1), t2(&log, 2), e1(&log, 3), after(&log, 4); + if_action.add_then_action(&t1); + if_action.add_then_action(&t2); + if_action.finish_then(); + if_action.add_else_action(&e1); + if_action.finish_else(); + ActionList<> top; + top.add_actions({&if_action, &after}); + top.play(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 2, 4, 3, 4})); + + log.clear(); + CountdownCondition loop_cond(2); + WhileAction<> loop(&loop_cond); + DeferredAction wait(&log, 5); + RecordAction after2(&log, 6); + loop.add_then_action(&wait); + loop.finish_then(); + ActionList<> top2; + top2.add_actions({&loop, &after2}); + top2.play(); + wait.finish(); + wait.finish(); + EXPECT_EQ(log, (Log{5, 5, 6})); + EXPECT_FALSE(top2.is_running()); +} + +// then: [] generates finish_then() with no actions +TEST(ActionBranchOwner, PerItemEmptyBranchesFinishImmediately) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + if_action.finish_then(); + if_action.finish_else(); + WhileAction<> loop(&cond); + loop.finish_then(); + RecordAction after(&log, 1), after2(&log, 2); + ActionList<> top, top2; + top.add_actions({&if_action, &after}); + top2.add_actions({&loop, &after2}); + top.play(); + cond.value = false; + top.play(); + cond.value = true; + top2.play(); + EXPECT_EQ(log, (Log{1, 1, 2})); + EXPECT_FALSE(top2.is_running()); +} + +TEST(ActionBranchOwner, ConditionThatStopsTheRunStartsNoBranch) { + Log log; + StoppingCondition cond; + IfAction if_action(&cond); + WhileAction<> loop(&cond); + RecordAction t1(&log, 1), e1(&log, 2), body(&log, 3), after(&log, 4); + if_action.add_then({&t1}); + if_action.add_else({&e1}); + loop.add_then({&body}); + ActionList<> if_list, while_list; + if_list.add_actions({&if_action, &after}); + while_list.add_actions({&loop, &after}); + + cond.list = &if_list; + if_list.play(); + cond.list = &while_list; + while_list.play(); + EXPECT_TRUE(log.empty()); + EXPECT_FALSE(if_list.is_running()); + EXPECT_FALSE(while_list.is_running()); +} + +TEST(ActionBranchOwner, WhileConditionStoppingOnALaterPassEndsTheLoop) { + Log log; + StopOnCheckCondition cond(2); + WhileAction<> loop(&cond); + RecordAction body(&log, 1), after(&log, 2); + loop.add_then({&body}); + ActionList<> list; + list.add_actions({&loop, &after}); + cond.list = &list; + + list.play(); + EXPECT_EQ(log, (Log{1})); + EXPECT_FALSE(list.is_running()); +} + +TEST(ActionBranchOwner, IfResumesAfterDeferredLastAction) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 2); + if_action.add_then({&wait}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1})); + EXPECT_TRUE(top.is_running()); + // num_running() counts the top-level chain only: the if, not the action inside its branch + EXPECT_EQ(top.num_running(), 1); + wait.finish(); + EXPECT_EQ(log, (Log{1, 2})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +// A deferred action that can hold two runs at once, completed in order +class QueueDeferredAction : public Action<> { + public: + void play_complex() override { + this->num_running_++; + this->pending_++; + } + void play() override {} + void finish_one() { + if (this->pending_ > 0) { + this->pending_--; + this->play_next_(); + } + } + int running() const { return this->num_running_; } + + protected: + int pending_{0}; +}; + +TEST(ActionBranchOwner, OverlappingRunsResumeOncePerRun) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + QueueDeferredAction wait; + RecordAction after(&log, 7); + if_action.add_then({&wait}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + top.play(); + EXPECT_EQ(wait.running(), 2); // the tagged last action holds both runs + wait.finish_one(); + EXPECT_EQ(log, (Log{7})); + EXPECT_TRUE(top.is_running()); + wait.finish_one(); + EXPECT_EQ(log, (Log{7, 7})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(wait.running(), 0); +} + +TEST(ActionBranchOwner, EmptyBranchesFinishImmediately) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction after(&log, 1); + if_action.add_then({}); + if_action.add_else({}); + ActionList<> top; + top.add_actions({&if_action, &after}); + top.play(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 1})); + + FlagCondition always; + WhileAction<> loop(&always); + loop.add_then({}); + ActionList<> top2; + RecordAction after2(&log, 2); + top2.add_actions({&loop, &after2}); + top2.play(); + EXPECT_EQ(log, (Log{1, 1, 2})); + EXPECT_FALSE(top2.is_running()); +} + +TEST(ActionBranchOwner, WhileRechecksEachPassAndExits) { + Log log; + CountdownCondition cond(3); + WhileAction<> loop(&cond); + RecordAction body(&log, 1); + RecordAction after(&log, 2); + loop.add_then({&body}); + ActionList<> top; + top.add_actions({&loop, &after}); + top.play(); + EXPECT_EQ(log, (Log{1, 1, 1, 2})); + EXPECT_FALSE(top.is_running()); +} + +TEST(ActionBranchOwner, StopMidBranchClearsRunning) { + Log log; + FlagCondition cond; + WhileAction<> loop(&cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 2); + loop.add_then({&wait}); + ActionList<> top; + top.add_actions({&loop, &after}); + + top.play(); + EXPECT_TRUE(top.is_running()); + top.stop(); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); + wait.finish(); // already stopped: nothing pending, nothing resumes + EXPECT_EQ(log, (Log{1})); + + // A fresh run works after the stop + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 2})); +} + +TEST(ActionBranchOwner, NestedIfInsideWhileResumesEachOwner) { + Log log; + CountdownCondition loop_cond(2); + FlagCondition if_cond; + WhileAction<> loop(&loop_cond); + IfAction inner(&if_cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 9); + inner.add_then({&wait}); + loop.add_then({&inner}); // the if is the last action of the loop body + ActionList<> top; + top.add_actions({&loop, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1})); + wait.finish(); // resumes the if, then the while, which loops again + EXPECT_EQ(log, (Log{1, 1})); + wait.finish(); // second pass ends; the while condition is now false + EXPECT_EQ(log, (Log{1, 1, 9})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +} // namespace esphome::core::testing diff --git a/tests/integration/fixtures/branch_resume_async.yaml b/tests/integration/fixtures/branch_resume_async.yaml index fb09323809..76654ddaa9 100644 --- a/tests/integration/fixtures/branch_resume_async.yaml +++ b/tests/integration/fixtures/branch_resume_async.yaml @@ -72,6 +72,26 @@ script: format: "parallel-after-if-%d" args: ['run'] + - id: stop_in_later_while_check + then: + - lambda: 'id(counter) = 0;' + - while: + condition: + lambda: |- + if (++id(counter) == 2) + id(stop_in_later_while_check).stop(); + return true; + then: + - lambda: 'ESP_LOGI("test", "later-check-body-%d", id(counter));' + - logger.log: "after-later-check-should-not-run" + - id: empty_while + then: + - while: + condition: + lambda: 'return true;' + then: [] + - logger.log: "after-empty-while" + - id: nested_if_last_in_while then: - lambda: 'id(counter) = 0;' @@ -206,6 +226,14 @@ api: ESP_LOGI("test", "stop-running=%d", id(stop_in_branch).is_running() ? 1 : 0); - delay: 600ms - logger.log: "stop-check-done" + - action: run_stop_in_later_while_check + then: + - script.execute: stop_in_later_while_check + - delay: 200ms + - logger.log: "later-check-done" + - action: run_empty_while + then: + - script.execute: empty_while - action: run_nested_if_last_in_while then: - script.execute: nested_if_last_in_while diff --git a/tests/integration/fixtures/continuation_actions.yaml b/tests/integration/fixtures/continuation_actions.yaml index bdfe149cb7..d48569f0f4 100644 --- a/tests/integration/fixtures/continuation_actions.yaml +++ b/tests/integration/fixtures/continuation_actions.yaml @@ -5,7 +5,7 @@ host: api: actions: - # Test 1: IfAction with ContinuationAction (then/else branches) + # Test 1: IfAction with then/else branches - action: test_if_action variables: condition: bool @@ -27,7 +27,7 @@ api: args: ['value'] - logger.log: "if completed" - # Test 2: Nested IfAction (multiple ContinuationAction instances) + # Test 2: Nested IfAction - action: test_nested_if variables: outer: bool @@ -51,7 +51,7 @@ api: - logger.log: "nested-outer-false" - logger.log: "nested if completed" - # Test 3: WhileAction with WhileLoopContinuation + # Test 3: WhileAction - action: test_while_action variables: max_count: int @@ -74,7 +74,7 @@ api: value: !lambda 'return id(continuation_test_counter) + 1;' - logger.log: "while completed" - # Test 4: RepeatAction with RepeatLoopContinuation + # Test 4: RepeatAction - action: test_repeat_action variables: count: int @@ -90,7 +90,7 @@ api: args: ['iteration'] - logger.log: "repeat completed" - # Test 5: Combined continuations (if + while + repeat) + # Test 5: Combined if + while + repeat - action: test_combined variables: do_loop: bool @@ -123,7 +123,7 @@ api: - logger.log: "combined-skipped" - logger.log: "combined completed" - # Test 6: Rapid triggers to verify memory efficiency + # Test 6: Rapid triggers - action: test_rapid_if then: - logger.log: "=== Rapid if test start ===" @@ -153,7 +153,7 @@ globals: restore_value: false initial_value: '0' -# Sensor to test rapid automation triggers with if/else (ContinuationAction) +# Sensor to test rapid automation triggers with if/else sensor: - platform: template id: rapid_sensor diff --git a/tests/integration/test_branch_resume_async.py b/tests/integration/test_branch_resume_async.py index 78064cbe30..f6fb5dbd45 100644 --- a/tests/integration/test_branch_resume_async.py +++ b/tests/integration/test_branch_resume_async.py @@ -73,6 +73,20 @@ async def test_branch_resume_async( assert count("stopped-branch-should-not-run") == 0 assert count("stopped-after-if-should-not-run") == 0 + # A while condition that stops the script on a later pass must not start the body again + done = wait_for("later-check-done") + await client.execute_service(service["run_stop_in_later_while_check"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("later-check-body-1") == 1 + assert count("later-check-body-2") == 0 + assert count("after-later-check-should-not-run") == 0 + + # An empty loop body cannot resume the loop, so the while finishes at once + done = wait_for("after-empty-while") + await client.execute_service(service["run_empty_while"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("after-empty-while") == 1 + done = wait_for("after-nested-while") await client.execute_service(service["run_nested_if_last_in_while"], {}) await asyncio.wait_for(done, timeout=2.0) diff --git a/tests/integration/test_continuation_actions.py b/tests/integration/test_continuation_actions.py index e6020c711a..1c6e8891a9 100644 --- a/tests/integration/test_continuation_actions.py +++ b/tests/integration/test_continuation_actions.py @@ -1,4 +1,4 @@ -"""Test continuation actions (ContinuationAction, WhileLoopContinuation, RepeatLoopContinuation).""" +"""Test that if, while and repeat resume the actions after them once their branch finishes.""" from __future__ import annotations @@ -17,10 +17,7 @@ async def test_continuation_actions( api_client_connected: APIClientConnectedFactory, ) -> None: """ - Test that continuation actions work correctly for if/while/repeat. - - These continuation classes replace LambdaAction with simple parent pointers, - saving 32-36 bytes per instance and eliminating std::function overhead. + Test that if/while/repeat resume the actions after them once their branch finishes. """ loop = asyncio.get_running_loop() @@ -218,7 +215,7 @@ async def test_continuation_actions( ) assert test_results["combined_complete"], "Combined did not complete" - # Test 6: Rapid triggers (tests memory efficiency of ContinuationAction) + # Test 6: Rapid triggers through an if/else test_service = next((s for s in services if s.name == "test_rapid_if"), None) assert test_service is not None, "test_rapid_if service not found" await client.execute_service(test_service, {}) From 99a4ae1f518454b7d95da31e14b6a8d2470b2615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:35:27 -1000 Subject: [PATCH 236/268] [ota] Add old_key so an upload can retry with the previous key (#19398) --- esphome/__main__.py | 17 +++ esphome/components/esphome/ota/__init__.py | 35 +++-- esphome/espota2.py | 96 +++++++++---- tests/component_tests/ota/test_esphome_ota.py | 75 +++++++++- tests/components/ota/old_key.yaml | 12 ++ .../ota/validate-old_key.esp8266-ard.yaml | 2 + tests/unit_tests/test_espota2.py | 17 +++ tests/unit_tests/test_espota2_noise.py | 95 ++++++++++++- tests/unit_tests/test_main.py | 129 +++++++++++------- 9 files changed, 384 insertions(+), 94 deletions(-) create mode 100644 tests/components/ota/old_key.yaml create mode 100644 tests/components/ota/validate-old_key.esp8266-ard.yaml diff --git a/esphome/__main__.py b/esphome/__main__.py index 0c164893df..2b094e67a1 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1376,6 +1376,7 @@ def _upload_via_native_api( # Fail closed: an encryption block whose key did not resolve must never # fall back to a plaintext upload noise_psk = None + old_noise_psk = None plaintext_fallback = False allow_plaintext_upload = False if ota_key := getattr(args, "ota_key", None): @@ -1396,6 +1397,8 @@ def _upload_via_native_api( # Ensure the key is a string, as required by the underlying OTA implementation. # It arrives here as a SensitiveStr which aioesphomeapi rejects. noise_psk = str(noise_psk) + if old_key := encryption_conf.get(espota2.CONF_OLD_KEY): + old_noise_psk = str(old_key) elif api_key := static_encryption_key(config.get(CONF_API) or {}): # Remove before 2027.3.0: the api key is tried, falling back to plaintext noise_psk = str(api_key) @@ -1445,9 +1448,22 @@ def _upload_via_native_api( noise_psk, plaintext_fallback=plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, + old_noise_psk=old_noise_psk, ) +def _old_key_notice(config: ConfigType) -> None: + """After `run`, whose image was just built from this config; a plain + `upload` may send a stale image that still carries the previous key.""" + from esphome import espota2 + + for item in config.get(CONF_OTA, []): + if item.get(CONF_PLATFORM) == CONF_ESPHOME and ( + item.get(CONF_ENCRYPTION) or {} + ).get(espota2.CONF_OLD_KEY): + _LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE) + + def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: @@ -1892,6 +1908,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code, successful_device = upload_program(config, args, devices) if exit_code == 0: _LOGGER.info("Successfully uploaded program.") + _old_key_notice(config) else: _LOGGER.warning("Failed to upload to %s", devices) return exit_code diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index dc4fc5b47e..7a0d03df52 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -5,6 +5,7 @@ from esphome.components.noise import ( ENCRYPTION_SCHEMA, new_psk_progmem, static_encryption_key, + validate_encryption_key, ) from esphome.components.ota import ( BASE_OTA_SCHEMA, @@ -32,7 +33,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority -from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD, CONF_OLD_KEY import esphome.final_validate as fv from esphome.types import ConfigType @@ -99,16 +100,18 @@ def ota_esphome_final_validate(config: ConfigType) -> None: # Encryption blocks conflict only when both pin a key; a bare # `encryption:` (a package/device split) is compatible with a # keyed one, and merge_config yields the keyed result - merged_key = ( - merged_ota_esphome_configs_by_port[conf_port] - .get(CONF_ENCRYPTION, {}) - .get(CONF_KEY) + merged_enc = merged_ota_esphome_configs_by_port[conf_port].get( + CONF_ENCRYPTION, {} ) - other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) - if merged_key and other_key and merged_key != other_key: - raise cv.Invalid( - f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent" - ) + other_enc = ota_conf.get(CONF_ENCRYPTION, {}) + for option in (CONF_KEY, CONF_OLD_KEY): + merged_key = merged_enc.get(option) + other_key = other_enc.get(option) + if merged_key and other_key and merged_key != other_key: + raise cv.Invalid( + f"Found multiple configurations but {CONF_ENCRYPTION} " + f"{option} is inconsistent" + ) ports_with_merged_configs.append(conf_port) merged_ota_esphome_configs_by_port[conf_port] = merge_config( @@ -206,7 +209,8 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - """Resolve the one encryption key per device into the ota block. An explicit ota key must match the api key, a bare block inherits it, - a runtime provisioned api key cannot be inherited. + a runtime provisioned api key cannot be inherited, and an old_key equal + to the resolved key is a leftover from a finished rotation. """ api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) if ota_key := encryption_conf.get(CONF_KEY): @@ -229,12 +233,19 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - ) else: encryption_conf[CONF_KEY] = api_key + if encryption_conf.get(CONF_OLD_KEY) == encryption_conf[CONF_KEY]: + raise cv.Invalid( + f"'{CONF_OLD_KEY}' is the same as '{CONF_KEY}'; remove '{CONF_OLD_KEY}' " + "once the device runs the new key" + ) # Uploader side options live only on the ota block; the api block keeps the -# shared schema +# shared schema. The firmware is built with `key` alone, `old_key` never +# reaches the device _ENCRYPTION_SCHEMA = ENCRYPTION_SCHEMA.extend( { + cv.Optional(CONF_OLD_KEY): cv.sensitive(validate_encryption_key), cv.Optional(CONF_ALLOW_PLAINTEXT_UPLOAD): cv.boolean, } ) diff --git a/esphome/espota2.py b/esphome/espota2.py index dbdf323366..f775afe7f6 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -215,14 +215,14 @@ class OTANetworkError(OTAError): """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" -# Remove before 2027.3.0 -class OTAEncryptionFallback(OTAError): - """The encrypted attempt failed and the caller may retry in plaintext.""" +class OTAKeyRejected(OTAError): + """The Noise handshake failed on the key; another key may succeed.""" -# Uploader side option under `ota: encryption:`; the ota component imports the -# name so the upload path never loads the component module +# Uploader side options under `ota: encryption:`; the ota component imports +# the names so the upload path never loads the component module CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload" +CONF_OLD_KEY = "old_key" ALLOW_PLAINTEXT_UPLOAD_NOTICE = ( f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that " "migrates a device which never encrypted. If this device encrypted before, " @@ -249,20 +249,43 @@ PLAINTEXT_FALLBACK_NOTICE = ( ) -# Remove before 2027.3.0 -class _EncryptionAttempt: - """The key an upload tries and whether it may fall back to plaintext; - a rejected handshake falls back at once, a transport fault only on repeat.""" +OLD_KEY_REMOVE_NOTICE = ( + "Once the device has booted this firmware it runs the current key; remove " + f"'{CONF_OLD_KEY}' from 'ota: encryption:'" +) - def __init__(self, noise_psk: str | None, plaintext_fallback: bool) -> None: + +class _EncryptionAttempt: + """The key an upload tries, the previous key to retry with once, and + (until 2027.3.0) whether it may fall back to plaintext.""" + + def __init__( + self, noise_psk: str | None, plaintext_fallback: bool, old_noise_psk: str | None + ) -> None: self.noise_psk = noise_psk + self.old_noise_psk = old_noise_psk + self.tried_old_key = False self.plaintext_fallback = plaintext_fallback self.handshake_faults = 0 + def retry_with_old_key(self) -> bool: + """Swap in the previous key once; the device may not run the new one yet.""" + if not self.old_noise_psk: + return False + _LOGGER.warning( + "Device rejected the OTA encryption key; retrying with '%s'", CONF_OLD_KEY + ) + self.noise_psk = self.old_noise_psk + self.old_noise_psk = None + self.tried_old_key = True + return True + + # Remove before 2027.3.0 def handshake_fault_falls_back(self) -> bool: self.handshake_faults += 1 return self.plaintext_fallback and self.handshake_faults >= 2 + # Remove before 2027.3.0 def downgrade(self, reason: str) -> None: _LOGGER.warning( "%s. Retrying in plaintext; a device that requires encryption " @@ -459,18 +482,20 @@ class NoiseSocketWrapper: if payload[0] != NOISE_HANDSHAKE_OK: reason = payload[1:].decode("utf-8", "replace") if reason == NOISE_MAC_FAILURE_REASON: - raise OTAError( + raise OTAKeyRejected( "Device rejected the handshake; is the OTA encryption key correct?" ) raise OTAError(f"Device rejected the noise handshake: {reason}") try: self._handshake.read_message(payload[1:]) - except (ValueError, self._invalid_tag) as err: - # InvalidTag is a wrong key; ValueError covers a device sending an - # invalid curve point, which cryptography rejects during the DH - raise OTAError( + except self._invalid_tag as err: + raise OTAKeyRejected( "Noise handshake failed; is the OTA encryption key correct?" ) from err + except ValueError as err: + # An invalid curve point, rejected by cryptography during the DH; + # the device is broken, not the key + raise OTAError(f"Noise handshake failed: {err}") from err self._encrypt, self._decrypt = self._handshake.get_ciphers() def sendall(self, data: bytes) -> None: @@ -652,11 +677,6 @@ def perform_ota( except OTANetworkError as err: # A transport fault: retry encrypted before considering plaintext raise OTAHandshakeNetworkError(str(err)) from err - except OTAError as err: - # Remove before 2027.3.0 - if plaintext_fallback: - raise OTAEncryptionFallback(str(err)) from err - raise _LOGGER.info("Encrypted connection established") if allow_plaintext_upload: _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) @@ -874,6 +894,7 @@ def run_ota_impl_( noise_psk: str | None = None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, + old_noise_psk: str | None = None, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -912,11 +933,12 @@ def run_ota_impl_( total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS last_error = "" reached_device = False + reconnect = False # the device closed on purpose; nothing to wait for attempt = 0 - encryption = _EncryptionAttempt(noise_psk, plaintext_fallback) + encryption = _EncryptionAttempt(noise_psk, plaintext_fallback, old_noise_psk) while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] - if reached_device or attempt >= len(res): + if not reconnect and (reached_device or attempt >= len(res)): _LOGGER.info( "Retrying in %.0f seconds (attempt %d of %d)...", UPLOAD_RETRY_DELAY, @@ -925,6 +947,7 @@ def run_ota_impl_( ) time.sleep(UPLOAD_RETRY_DELAY) reached_device = False + reconnect = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -951,11 +974,22 @@ def run_ota_impl_( encryption.plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, ) - except OTAEncryptionFallback as err: - # Same address and attempt budget: not a network retry + except OTAKeyRejected as err: + # The device sent a reject and closed on its own, so reconnect + # at once, on the same address and attempt budget last_error = str(err) - encryption.downgrade(last_error) - continue + reconnect = True + if encryption.retry_with_old_key(): + continue + # Remove before 2027.3.0 + if encryption.plaintext_fallback: + encryption.downgrade(last_error) + continue + if encryption.tried_old_key: + raise OTAKeyRejected( + f"The device rejected both 'key' and '{CONF_OLD_KEY}'" + ) from err + raise except OTAHandshakeNetworkError as err: last_error = str(err) if encryption.handshake_fault_falls_back(): @@ -976,7 +1010,13 @@ def run_ota_impl_( _LOGGER.error(str(err)) return 1, None - # Successfully uploaded to sa[0] + if encryption.tried_old_key: + # Kept next to the result: a device that should already run the + # current key but answered to the previous one is worth a look + _LOGGER.warning( + "The device accepted '%s'; it was still running the previous key", + CONF_OLD_KEY, + ) return 0, sa[0] _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) @@ -992,6 +1032,7 @@ def run_ota( noise_psk: str | None = None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, + old_noise_psk: str | None = None, ) -> tuple[int, str | None]: try: return run_ota_impl_( @@ -1003,6 +1044,7 @@ def run_ota( noise_psk, plaintext_fallback, allow_plaintext_upload, + old_noise_psk, ) except OTAError as err: _LOGGER.error(err) diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 77db149923..39920ff436 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -30,7 +30,7 @@ from esphome.const import ( CONF_VERSION, ) from esphome.core import CORE, ID -from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD, CONF_OLD_KEY import esphome.final_validate as fv @@ -215,6 +215,77 @@ def test_encryption_schema_allow_plaintext_upload() -> None: encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) +def test_encryption_old_key_schema() -> None: + """old_key is validated like key and is an ota only option.""" + conf = _encryption_schema({CONF_KEY: API_KEY, CONF_OLD_KEY: OTHER_KEY}) + assert conf[CONF_OLD_KEY] == OTHER_KEY + with pytest.raises(cv.Invalid, match="base64"): + _encryption_schema({CONF_OLD_KEY: "short"}) + with pytest.raises(cv.Invalid): + encryption_schema({CONF_OLD_KEY: OTHER_KEY}) + + +def test_encryption_old_key_keeps_inherited_key() -> None: + """A bare block with old_key inherits the api key as the current one.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_OLD_KEY: OTHER_KEY}}) + ], + } + token = fv.full_config.set(full_conf) + try: + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == API_KEY + assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_OLD_KEY] == OTHER_KEY + finally: + fv.full_config.reset(token) + + +def test_encryption_old_key_mismatch_between_merged_configs_rejected() -> None: + """Same-port blocks that pin different old keys raise like differing keys.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_OLD_KEY: OTHER_KEY}}), + _make_ota_config( + port=3232, + **{ + CONF_ENCRYPTION: { + CONF_OLD_KEY: "AgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICE=" + } + }, + ), + ], + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises(cv.Invalid, match="old_key is inconsistent"): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_encryption_old_key_equal_to_key_rejected() -> None: + """An old_key that matches key is a leftover from a finished rotation.""" + full_conf = { + CONF_API: {}, + CONF_OTA: [ + _make_ota_config( + port=3232, + **{CONF_ENCRYPTION: {CONF_KEY: API_KEY, CONF_OLD_KEY: API_KEY}}, + ) + ], + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises(cv.Invalid, match="'old_key' is the same as 'key'"): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: """Same-port configs with different encryption keys raise.""" full_conf = { @@ -225,7 +296,7 @@ def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: } token = fv.full_config.set(full_conf) try: - with pytest.raises(cv.Invalid, match="encryption is inconsistent"): + with pytest.raises(cv.Invalid, match="encryption key is inconsistent"): ota_esphome_final_validate({}) finally: fv.full_config.reset(token) diff --git a/tests/components/ota/old_key.yaml b/tests/components/ota/old_key.yaml new file mode 100644 index 0000000000..8074ca06f6 --- /dev/null +++ b/tests/components/ota/old_key.yaml @@ -0,0 +1,12 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + encryption: + old_key: "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" diff --git a/tests/components/ota/validate-old_key.esp8266-ard.yaml b/tests/components/ota/validate-old_key.esp8266-ard.yaml new file mode 100644 index 0000000000..f58187f455 --- /dev/null +++ b/tests/components/ota/validate-old_key.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include old_key.yaml diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f4c5935059..6fa6c616b6 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -799,6 +799,23 @@ def test_run_ota_impl_connection_failed( mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_old_key_reconnect_never_waits( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """The reconnect with old_key skips the retry delay even once the + address list has been cycled by an earlier connect failure.""" + mock_socket.connect.side_effect = [OSError("timed out"), None, None] + mock_perform_ota.side_effect = [espota2.OTAKeyRejected("rejected"), None] + + result_code, _ = espota2.run_ota_impl_( + "test.local", 3232, None, str(firmware_file), noise_psk="a", old_noise_psk="b" + ) + + assert result_code == 0 + assert mock_sleep.call_count == 1 # only the connect failure waited + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_connect_retry_succeeds( mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 60f6b28e07..ca50641309 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -70,10 +70,12 @@ class FakeEncryptedDevice(threading.Thread): prologue_features_override: int | None = None, connections: int = 1, drop_handshakes: int = 0, + reject_reason: str | None = None, ) -> None: super().__init__(daemon=True) self.connections = connections self.drop_handshakes = drop_handshakes # hang up mid-handshake this many times + self.reject_reason = reject_reason # refuse every handshake with this reason self.psk = psk self.version = version self.offer_noise = offer_noise @@ -160,6 +162,9 @@ class FakeEncryptedDevice(threading.Thread): if self.drop_handshakes > 0: self.drop_handshakes -= 1 return # a transport fault: the socket closes with no reply + if self.reject_reason is not None: + _send_frame(sock, b"\x01" + self.reject_reason.encode()) + return try: proto.read_message(msg1[1:]) except InvalidTag: @@ -244,8 +249,9 @@ def _run_ota( tmp_path: Path, noise_psk: str, plaintext_fallback: bool = True, + old_noise_psk: str | None = None, ) -> int: - """Drive the retry loop, which is where the plaintext fallback reconnects.""" + """Drive the retry loop, which is where the fallback and the old key reconnect.""" path = tmp_path / "firmware.bin" path.write_bytes(firmware) device.start() @@ -256,10 +262,62 @@ def _run_ota( path, noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, + old_noise_psk=old_noise_psk, ) return rc +THIRD_PSK = base64.b64encode(bytes(range(2, 34))).decode() + + +@pytest.mark.parametrize( + ("device_psk", "old_noise_psk", "expected_rc", "retried"), + [ + # The device still runs the previous key: one reconnect with old_key + (OTHER_PSK, OTHER_PSK, 0, True), + # The device already runs the new key: old_key is never presented + (PSK, OTHER_PSK, 0, False), + # Neither key matches: the retry is spent, then it fails closed + (THIRD_PSK, OTHER_PSK, 1, True), + # No old_key configured: a rejected key is a device error + (OTHER_PSK, None, 1, False), + ], + ids=["old_key_accepted", "key_accepted", "both_rejected", "no_old_key"], +) +def test_old_key_retry( + caplog: pytest.LogCaptureFixture, + tmp_path: Path, + device_psk: str, + old_noise_psk: str | None, + expected_rc: int, + retried: bool, +) -> None: + pytest.importorskip("aioesphomeapi.noise") + firmware = b"firmware" + device = FakeEncryptedDevice(psk=device_psk, connections=2 if retried else 1) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota( + device, + firmware, + tmp_path, + PSK, + plaintext_fallback=False, + old_noise_psk=old_noise_psk, + ) + device.join_and_check() + assert rc == expected_rc + assert (device.received == firmware) is (expected_rc == 0) + assert ( + any("retrying with 'old_key'" in r.message for r in caplog.records) is retried + ) + assert any("accepted 'old_key'" in r.message for r in caplog.records) is ( + retried and expected_rc == 0 + ) + assert not any("plaintext" in r.message for r in caplog.records) + if expected_rc == 1 and old_noise_psk is not None: + assert any("rejected both" in r.message for r in caplog.records) + + def test_encrypted_upload_success() -> None: """A full encrypted v2 upload spanning several 8192-byte blocks.""" pytest.importorskip("aioesphomeapi.noise") @@ -495,21 +553,21 @@ def test_handshake_reject_with_other_reason() -> None: def test_handshake_garbage_second_message() -> None: - """A valid-looking point with a garbage MAC fails cleanly.""" + """A valid-looking point with a garbage MAC is a key failure.""" wrapper = _wrapper(_frame(b"\x00" + bytes(range(48)))) with pytest.raises( - espota2.OTAError, match="handshake failed; is the OTA encryption key" + espota2.OTAKeyRejected, match="handshake failed; is the OTA encryption key" ): wrapper.do_handshake() def test_handshake_invalid_curve_point() -> None: - """An all-zero x25519 point is rejected as a clean error, not a crash.""" + """An all-zero x25519 point is a clean error, not a crash, and not a + key failure: it must not spend the old_key retry.""" wrapper = _wrapper(_frame(b"\x00" + bytes(48))) - with pytest.raises( - espota2.OTAError, match="handshake failed; is the OTA encryption key" - ): + with pytest.raises(espota2.OTAError, match="handshake failed: ") as info: wrapper.do_handshake() + assert not isinstance(info.value, espota2.OTAKeyRejected) def test_recv_closed_at_frame_boundary_returns_empty() -> None: @@ -585,6 +643,29 @@ def test_recv_serves_buffered_plaintext_without_new_frame() -> None: wrapper._decrypt.decrypt.assert_called_once() +@pytest.mark.parametrize("plaintext_fallback", [False, True]) +def test_non_key_reject_reason_is_a_device_error( + caplog: pytest.LogCaptureFixture, tmp_path: Path, plaintext_fallback: bool +) -> None: + """Only a key failure spends the old_key retry or, until 2027.3.0, the + plaintext fallback; another reject reason fails at once.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(reject_reason="Busy") + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota( + device, + b"firmware", + tmp_path, + PSK, + plaintext_fallback=plaintext_fallback, + old_noise_psk=OTHER_PSK, + ) + device.join_and_check() + assert rc == 1 + assert not any("retrying with 'old_key'" in r.message for r in caplog.records) + assert not any("Retrying in plaintext" in r.message for r in caplog.records) + + def test_bare_block_refuses_a_device_that_cannot_encrypt( caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 3b10c9cb3c..801eda4496 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1558,6 +1558,14 @@ class MockArgs: env_ota_key: str | None = None +# The keyword half of every run_ota call; a test overrides the one it is about +RUN_OTA_DEFAULT_KWARGS = { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + "old_noise_psk": None, +} + + def test_upload_program_serial_esp32( mock_upload_using_esptool: Mock, mock_get_port_type: Mock, @@ -2120,32 +2128,39 @@ def test_upload_program_ota_success( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) +@pytest.mark.parametrize( + "old_key", + [None, "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="], + ids=["key", "old_key"], +) def test_upload_program_ota_encryption_key( mock_run_ota: Mock, mock_get_port_type: Mock, tmp_path: Path, + old_key: str | None, + caplog: pytest.LogCaptureFixture, ) -> None: - """The resolved encryption key is passed through to run_ota.""" + """The resolved encryption key, and the previous one when set, are passed + through to run_ota.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) mock_get_port_type.return_value = "NETWORK" mock_run_ota.return_value = (0, "192.168.1.100") key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + encryption = {CONF_KEY: key} + if old_key is not None: + encryption["old_key"] = old_key config = { CONF_OTA: [ - { - CONF_PLATFORM: CONF_ESPHOME, - CONF_PORT: 3232, - CONF_ENCRYPTION: {CONF_KEY: key}, - } + {CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, CONF_ENCRYPTION: encryption} ] } - exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"]) + with caplog.at_level(logging.WARNING): + exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"]) assert exit_code == 0 assert host == "192.168.1.100" @@ -2159,9 +2174,10 @@ def test_upload_program_ota_encryption_key( expected_firmware, OTA_TYPE_UPDATE_APP, key, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS | {"old_noise_psk": old_key}, ) + # The notice belongs to run, whose image was just built from this config + assert not any("remove 'old_key'" in r.message for r in caplog.records) def test_upload_program_bare_encryption_block_never_falls_back( @@ -2191,10 +2207,7 @@ def test_upload_program_bare_encryption_block_never_falls_back( with patch("esphome.espota2.run_ota", return_value=(0, "192.168.1.100")) as run_ota: upload_program(config, MockArgs(), ["192.168.1.100"]) assert run_ota.call_args.args[5] == key - assert run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_ota_allow_plaintext_upload( @@ -2223,9 +2236,8 @@ def test_upload_program_ota_allow_plaintext_upload( assert exit_code == 0 assert mock_run_ota.call_args.args[2] == "pw" assert mock_run_ota.call_args.args[5] == key - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": True, + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS | { + "allow_plaintext_upload": True } @@ -2235,6 +2247,7 @@ def test_upload_program_ota_allow_plaintext_upload( { CONF_ENCRYPTION: { CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "old_key": "AgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICE=", "allow_plaintext_upload": True, } }, @@ -2265,10 +2278,7 @@ def test_upload_program_ota_prompted_key_is_presented( assert exit_code == 0 assert mock_run_ota.call_args.args[5] == old_key - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_serial_ignores_prompted_key( @@ -2320,6 +2330,46 @@ def test_command_run_prompts_for_ota_key_before_compile() -> None: assert calls == ["prompt", "compile"] +@pytest.mark.parametrize( + "old_key", [None, "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="] +) +def test_command_run_says_when_old_key_can_go( + caplog: pytest.LogCaptureFixture, old_key: str | None +) -> None: + """Only run has just built the image from this config, so only run can + say the device will run `key` alone once it boots.""" + encryption = {CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} + if old_key is not None: + encryption["old_key"] = old_key + setup_core( + config={ + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_ENCRYPTION: encryption, + } + ] + }, + platform=PLATFORM_ESP32, + ) + args = MockArgs() + args.device = ["dev.local"] + args.no_logs = True + with ( + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + patch("esphome.__main__.choose_upload_log_host", return_value=["dev.local"]), + patch("esphome.__main__.get_serial_ports", return_value=[]), + patch("esphome.__main__.upload_program", return_value=(0, "dev.local")), + caplog.at_level(logging.WARNING), + ): + assert command_run(args, CORE.config) == 0 + assert any("remove 'old_key'" in r.message for r in caplog.records) is ( + old_key is not None + ) + + def test_upload_program_platform_hook_ignores_prompted_key( mock_get_port_type: Mock, tmp_path: Path, @@ -2636,8 +2686,7 @@ def test_upload_program_ota_api_key_opportunistic( expected_firmware, OTA_TYPE_UPDATE_APP, key, - plaintext_fallback=True, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS | {"plaintext_fallback": True}, ) @@ -2666,10 +2715,7 @@ def test_upload_program_ota_no_usable_api_key_stays_plaintext( assert exit_code == 0 assert mock_run_ota.call_args.args[5] is None - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_ota_encryption_without_key_fails_closed( @@ -2728,8 +2774,7 @@ def test_upload_program_ota_with_file_arg( Path("custom.bin"), OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -2785,8 +2830,7 @@ def test_upload_program_ota_partition_table_with_file_arg( partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -2849,8 +2893,7 @@ def test_upload_program_ota_partition_table_mqttip( partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -3039,8 +3082,7 @@ def test_upload_program_ota_bootloader_with_file_arg( bootloader_file, OTA_TYPE_UPDATE_BOOTLOADER, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -3551,8 +3593,7 @@ def test_upload_program_ota_with_mqtt_resolution( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -3608,8 +3649,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -5161,8 +5201,7 @@ def test_upload_program_ota_static_ip_with_mqttip( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -5213,8 +5252,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -5398,8 +5436,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) From 318e4b79c110917303e49aade9d6fa13601ace63 Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Mon, 12 Oct 2026 17:38:34 -0600 Subject: [PATCH 237/268] [apc1] Add support for the ScioSense APC1 air quality sensor (UART) (#19274) Co-authored-by: J. Nick Koston Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/apc1/__init__.py | 3 + esphome/components/apc1/apc1.cpp | 352 ++++++++++++++++++++ esphome/components/apc1/apc1.h | 99 ++++++ esphome/components/apc1/sensor.py | 222 ++++++++++++ tests/components/apc1/common.yaml | 62 ++++ tests/components/apc1/test.esp32-idf.yaml | 7 + tests/components/apc1/test.esp8266-ard.yaml | 7 + tests/components/apc1/test.rp2040-ard.yaml | 7 + 9 files changed, 760 insertions(+) create mode 100644 esphome/components/apc1/__init__.py create mode 100644 esphome/components/apc1/apc1.cpp create mode 100644 esphome/components/apc1/apc1.h create mode 100644 esphome/components/apc1/sensor.py create mode 100644 tests/components/apc1/common.yaml create mode 100644 tests/components/apc1/test.esp32-idf.yaml create mode 100644 tests/components/apc1/test.esp8266-ard.yaml create mode 100644 tests/components/apc1/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f7875eb140..fab41f5b1f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -39,6 +39,7 @@ esphome/components/am43/sensor/* @buxtronix esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix +esphome/components/apc1/* @ademuri esphome/components/apds9306/* @aodrenah esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core diff --git a/esphome/components/apc1/__init__.py b/esphome/components/apc1/__init__.py new file mode 100644 index 0000000000..355ecc2305 --- /dev/null +++ b/esphome/components/apc1/__init__.py @@ -0,0 +1,3 @@ +"""Support for ScioSense APC1 air quality sensor.""" + +DOMAIN = "apc1" diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp new file mode 100644 index 0000000000..beca9d8581 --- /dev/null +++ b/esphome/components/apc1/apc1.cpp @@ -0,0 +1,352 @@ +#include "apc1.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include + +namespace esphome::apc1 { + +ESPHOME_LOG_TAG(TAG, "apc1"); + +static constexpr uint8_t APC1_MEASUREMENT_OFFSET_AQI = 58; +static constexpr uint8_t APC1_MEASUREMENT_OFFSET_ERROR_CODE = 61; + +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_MODULE = 4; +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_SERIAL = 10; +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_FW = 19; + +// Give a freshly powered sensor time to boot before the first retry +static constexpr uint32_t APC1_COLD_BOOT_INIT_DELAY_MS = 2000; +// Re-send the init commands at most this often while no frames arrive +static constexpr uint32_t APC1_INIT_RETRY_INTERVAL_MS = 10000; +// Active mode streams a frame every second; this long without one means the sensor stopped +static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000; +// Drop a partial frame when the bytes stop mid-way +static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200; + +// Frames and commands end with the 16-bit sum of every byte before it +static uint16_t apc1_checksum(const uint8_t *data, size_t len) { + uint16_t sum = 0; + for (size_t i = 0; i < len; i++) { + sum += data[i]; + } + return sum; +} + +void APC1Component::setup() { + if (this->set_pin_ != nullptr) { + this->set_pin_->setup(); + this->set_pin_->digital_write(true); + } + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + + // Attempt initial communication immediately for warm restarts + this->initialize_device_(); + + // Schedule delayed initialization to catch sensors during cold power-on bootup + this->set_timeout(APC1_COLD_BOOT_INIT_DELAY_MS, [this]() { this->initialize_device_(); }); +} + +void APC1Component::initialize_device_() { + this->last_init_attempt_ = App.get_loop_component_start_time(); + if (this->active_mode_ && !this->idle_) { + this->set_active_mode(); + } + this->send_command_(APC1Command::APC1_COMMAND_READ_SENSOR_VERSION, 0x0000); +} + +void APC1Component::dump_config() { + ESP_LOGCONFIG(TAG, "APC1:"); + LOG_PIN(" Set Pin: ", this->set_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_); + LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); + LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_); + LOG_SENSOR(" ", "PM1.0 Standard", this->pm_1_0_std_sensor_); + LOG_SENSOR(" ", "PM2.5 Standard", this->pm_2_5_std_sensor_); + LOG_SENSOR(" ", "PM10.0 Standard", this->pm_10_0_std_sensor_); + LOG_SENSOR(" ", "PM >0.3um", this->pm_0_3um_sensor_); + LOG_SENSOR(" ", "PM >0.5um", this->pm_0_5um_sensor_); + LOG_SENSOR(" ", "PM >1.0um", this->pm_1_0um_sensor_); + LOG_SENSOR(" ", "PM >2.5um", this->pm_2_5um_sensor_); + LOG_SENSOR(" ", "PM >5.0um", this->pm_5_0um_sensor_); + LOG_SENSOR(" ", "PM >10.0um", this->pm_10_0um_sensor_); + LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_); + LOG_SENSOR(" ", "eCO2", this->eco2_sensor_); + LOG_SENSOR(" ", "AQI", this->aqi_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Raw Temperature", this->raw_temperature_sensor_); + LOG_SENSOR(" ", "Raw Humidity", this->raw_humidity_sensor_); + LOG_SENSOR(" ", "RS0", this->rs0_sensor_); + LOG_SENSOR(" ", "RS2", this->rs2_sensor_); + LOG_SENSOR(" ", "RS3", this->rs3_sensor_); + LOG_SENSOR(" ", "Error Code", this->error_code_sensor_); +} + +void APC1Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + + // If in active mode and not idle, retry periodically if no valid data received + if (this->active_mode_ && !this->idle_) { + if (this->last_valid_frame_ == 0) { + if (now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) { + ESP_LOGD(TAG, "Waiting for APC1 sensor to respond, sending init commands..."); + this->initialize_device_(); + } + } else if (now - this->last_valid_frame_ > APC1_NO_DATA_TIMEOUT_MS && + now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) { + ESP_LOGW(TAG, "No data received from APC1 for %" PRIu32 " ms, re-initializing...", now - this->last_valid_frame_); + this->initialize_device_(); + } + } + + if (this->rx_index_ > 0 && now - this->last_transmission_ > APC1_RX_IDLE_TIMEOUT_MS) { + ESP_LOGV(TAG, "Timed out waiting for data, resetting buffer"); + this->rx_index_ = 0; + } + + while (this->available()) { + this->last_transmission_ = now; + uint8_t byte; + this->read_byte(&byte); + + if (this->rx_index_ == 0) { + if (byte == APC1_HEADER_1) { + this->rx_buffer_[this->rx_index_++] = byte; + } + continue; + } + + if (this->rx_index_ == 1) { + if (byte == APC1_HEADER_2) { + this->rx_buffer_[this->rx_index_++] = byte; + } else if (byte != APC1_HEADER_1) { + this->rx_index_ = 0; + } + continue; + } + + this->rx_buffer_[this->rx_index_++] = byte; + if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) { + continue; + } + + // The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a + // corrupt length from wrapping the total and walking past the buffer + const uint16_t payload_len = encode_uint16(this->rx_buffer_[2], this->rx_buffer_[3]); + if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) { + ESP_LOGW(TAG, "Invalid frame length: %u", payload_len); + this->rx_index_ = 0; + continue; + } + const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE; + if (this->rx_index_ == total_len) { + this->parse_frame_(total_len); + this->rx_index_ = 0; + } + } +} + +void APC1Component::parse_frame_(uint16_t total_len) { + uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]); + uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2); + if (expected_checksum != calculated_checksum) { + ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum, + expected_checksum); + return; + } + + if (total_len == APC1_FRAME_SIZE_MEASUREMENT) { + this->parse_measurement_frame_(); + } else if (total_len == APC1_FRAME_SIZE_DEVICE_INFO) { + this->parse_device_info_frame_(); + } else if (total_len == APC1_FRAME_SIZE_COMMAND_RESPONSE) { + ESP_LOGD(TAG, "APC1 command response: cmd=0x%02X, data=0x%02X", this->rx_buffer_[4], this->rx_buffer_[5]); + } else { + ESP_LOGD(TAG, "Received frame of unknown length %u", total_len); + } +} + +// One message per error bit, bit 0 first +PROGMEM_STRING_TABLE(ErrorStrings, "Fan restart error (too many restarts)", "Fan speed low", "Photodiode fault", + "Fan stopped / startup error", "Laser fault", "VOC sensor fault", "RHT sensor fault"); + +void APC1Component::parse_measurement_frame_() { + const uint32_t now = App.get_loop_component_start_time(); + this->last_valid_frame_ = now; + + uint8_t error_code = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_ERROR_CODE]; + if (error_code != this->last_error_code_) { + if (error_code != 0) { + for (uint8_t bit = 0; bit < ErrorStrings::COUNT; bit++) { + if (error_code & (1 << bit)) { + ESP_LOGW(TAG, "APC1: %s", LOG_STR_ARG(ErrorStrings::get_log_str(bit, ErrorStrings::LAST_INDEX))); + } + } + this->status_set_warning(LOG_STR("Sensor reported hardware error")); + } else { + ESP_LOGI(TAG, "APC1: Hardware error cleared"); + this->status_clear_warning(); + } + this->last_error_code_ = error_code; + if (this->error_code_sensor_ != nullptr) { + this->error_code_sensor_->publish_state(error_code); + } + } + + uint8_t aqi = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_AQI]; + if (aqi == 0) { + if (!this->gas_warming_up_) { + this->gas_warming_up_ = true; + ESP_LOGI(TAG, "APC1: Gas sensor warming up (AQI=0)"); + } + } else if (this->gas_warming_up_) { + this->gas_warming_up_ = false; + ESP_LOGI(TAG, "APC1: Gas sensor warm-up complete (AQI=%u)", aqi); + } + + // Measurement payload data (bytes 4-57): big-endian sensor readings. + // The values below feed the debug summary, so they are always decoded; every + // other reading is only decoded when its sensor is configured. + const auto u16 = [this](uint8_t offset) { + return encode_uint16(this->rx_buffer_[offset], this->rx_buffer_[offset + 1]); + }; + const auto u32 = [this](uint8_t offset) { + return encode_uint32(this->rx_buffer_[offset], this->rx_buffer_[offset + 1], this->rx_buffer_[offset + 2], + this->rx_buffer_[offset + 3]); + }; + uint16_t pm_1_0 = u16(10); + uint16_t pm_2_5 = u16(12); + uint16_t pm_10_0 = u16(14); + uint16_t tvoc = u16(28); + uint16_t eco2 = u16(30); + float temp_comp = static_cast(u16(34)) / 10.0f; + float hum_comp = u16(36) / 10.0f; + + ESP_LOGD(TAG, "APC1: PM1.0: %u, PM2.5: %u, PM10: %u, TVOC: %u, eCO2: %u, AQI: %u, Temp: %.1f°C, Hum: %.1f%%", pm_1_0, + pm_2_5, pm_10_0, tvoc, eco2, aqi, temp_comp, hum_comp); + + if (this->pm_1_0_std_sensor_ != nullptr) + this->pm_1_0_std_sensor_->publish_state(u16(4)); + if (this->pm_2_5_std_sensor_ != nullptr) + this->pm_2_5_std_sensor_->publish_state(u16(6)); + if (this->pm_10_0_std_sensor_ != nullptr) + this->pm_10_0_std_sensor_->publish_state(u16(8)); + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0); + + if (this->pm_0_3um_sensor_ != nullptr) + this->pm_0_3um_sensor_->publish_state(u16(16)); + if (this->pm_0_5um_sensor_ != nullptr) + this->pm_0_5um_sensor_->publish_state(u16(18)); + if (this->pm_1_0um_sensor_ != nullptr) + this->pm_1_0um_sensor_->publish_state(u16(20)); + if (this->pm_2_5um_sensor_ != nullptr) + this->pm_2_5um_sensor_->publish_state(u16(22)); + if (this->pm_5_0um_sensor_ != nullptr) + this->pm_5_0um_sensor_->publish_state(u16(24)); + if (this->pm_10_0um_sensor_ != nullptr) + this->pm_10_0um_sensor_->publish_state(u16(26)); + + if (this->tvoc_sensor_ != nullptr) + this->tvoc_sensor_->publish_state(tvoc); + if (this->eco2_sensor_ != nullptr) + this->eco2_sensor_->publish_state(eco2); + if (this->aqi_sensor_ != nullptr) + this->aqi_sensor_->publish_state(aqi > 0 ? aqi : NAN); + + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temp_comp); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(hum_comp); + + if (this->raw_temperature_sensor_ != nullptr) { + this->raw_temperature_sensor_->publish_state(static_cast(u16(38)) / 10.0f); + } + if (this->raw_humidity_sensor_ != nullptr) + this->raw_humidity_sensor_->publish_state(u16(40) / 10.0f); + + if (this->rs0_sensor_ != nullptr) { + this->rs0_sensor_->publish_state(u32(42)); + } + if (this->rs2_sensor_ != nullptr) { + this->rs2_sensor_->publish_state(u32(50)); + } + if (this->rs3_sensor_ != nullptr) { + this->rs3_sensor_->publish_state(u32(54)); + } +} + +void APC1Component::parse_device_info_frame_() { + const uint8_t *serial = this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_SERIAL; + uint16_t fw_version = + encode_uint16(this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW], this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW + 1]); + ESP_LOGI(TAG, "APC1: Module: %.6s, Serial: %08" PRIX32 "%08" PRIX32 ", Firmware: 0x%04X (%u)", + reinterpret_cast(this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_MODULE), + encode_uint32(serial[0], serial[1], serial[2], serial[3]), + encode_uint32(serial[4], serial[5], serial[6], serial[7]), fw_version, fw_version); +} + +void APC1Component::arm_watchdog_() { + this->last_valid_frame_ = App.get_loop_component_start_time(); + this->last_init_attempt_ = this->last_valid_frame_; +} + +void APC1Component::set_active_mode() { + if (!this->active_mode_) { + this->arm_watchdog_(); + } + this->active_mode_ = true; + this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_ACTIVE); +} + +void APC1Component::set_passive_mode() { + this->active_mode_ = false; + this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_PASSIVE); +} + +void APC1Component::request_measurement() { + this->send_command_(APC1Command::APC1_COMMAND_REQUEST_MEASUREMENT, 0x0000); +} + +void APC1Component::set_idle_mode() { + this->idle_ = true; + this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_IDLE); +} + +void APC1Component::set_measurement_mode() { + this->idle_ = false; + this->arm_watchdog_(); + this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_STANDARD); +} + +void APC1Component::send_command_(APC1Command cmd, uint16_t data) { + std::array buf{ + APC1_HEADER_1, + APC1_HEADER_2, + static_cast(cmd), + static_cast((data >> 8) & 0xFF), + static_cast(data & 0xFF), + 0, + 0, + }; + uint16_t checksum = apc1_checksum(buf.data(), 5); + buf[5] = static_cast((checksum >> 8) & 0xFF); + buf[6] = static_cast(checksum & 0xFF); + this->write_array(buf); +} + +} // namespace esphome::apc1 diff --git a/esphome/components/apc1/apc1.h b/esphome/components/apc1/apc1.h new file mode 100644 index 0000000000..850738ead6 --- /dev/null +++ b/esphome/components/apc1/apc1.h @@ -0,0 +1,99 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::apc1 { + +static constexpr uint8_t APC1_HEADER_1 = 0x42; +static constexpr uint8_t APC1_HEADER_2 = 0x4D; +static constexpr uint8_t APC1_FRAME_HEADER_SIZE = 4; // 2 start bytes (0x42, 0x4D) + 2 frame length bytes +static constexpr uint8_t APC1_FRAME_SIZE_MEASUREMENT = 64; +static constexpr uint8_t APC1_FRAME_SIZE_DEVICE_INFO = 23; +static constexpr uint8_t APC1_FRAME_SIZE_COMMAND_RESPONSE = 8; +static constexpr uint8_t APC1_COMMAND_FRAME_SIZE = 7; + +enum class APC1Command : uint8_t { + APC1_COMMAND_MEASUREMENT_MODE = 0xE1, + APC1_COMMAND_REQUEST_MEASUREMENT = 0xE2, + APC1_COMMAND_OPERATION_MODE = 0xE4, + APC1_COMMAND_READ_SENSOR_VERSION = 0xE9, +}; + +static constexpr uint16_t APC1_MEASUREMENT_MODE_PASSIVE = 0x0000; +static constexpr uint16_t APC1_MEASUREMENT_MODE_ACTIVE = 0x0001; + +static constexpr uint16_t APC1_OPERATING_MODE_IDLE = 0x0000; +static constexpr uint16_t APC1_OPERATING_MODE_STANDARD = 0x0001; + +class APC1Component final : public uart::UARTDevice, public Component { + SUB_SENSOR(pm_1_0) + SUB_SENSOR(pm_2_5) + SUB_SENSOR(pm_10_0) + SUB_SENSOR(pm_1_0_std) + SUB_SENSOR(pm_2_5_std) + SUB_SENSOR(pm_10_0_std) + + SUB_SENSOR(pm_0_3um) + SUB_SENSOR(pm_0_5um) + SUB_SENSOR(pm_1_0um) + SUB_SENSOR(pm_2_5um) + SUB_SENSOR(pm_5_0um) + SUB_SENSOR(pm_10_0um) + + SUB_SENSOR(tvoc) + SUB_SENSOR(eco2) + SUB_SENSOR(aqi) + + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(raw_temperature) + SUB_SENSOR(raw_humidity) + + SUB_SENSOR(rs0) + SUB_SENSOR(rs2) + SUB_SENSOR(rs3) + + SUB_SENSOR(error_code) + + public: + void setup() override; + void dump_config() override; + void loop() override; + + void set_active_mode(); + void set_passive_mode(); + void request_measurement(); + void set_idle_mode(); + void set_measurement_mode(); + + void set_set_pin(GPIOPin *set_pin) { this->set_pin_ = set_pin; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + + protected: + void send_command_(APC1Command cmd, uint16_t data); + void initialize_device_(); + /// Restarts the no-data watchdog so a mode change gets the same grace period as a cold boot + void arm_watchdog_(); + void parse_frame_(uint16_t total_len); + void parse_measurement_frame_(); + void parse_device_info_frame_(); + + GPIOPin *set_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + uint32_t last_transmission_{0}; + uint32_t last_valid_frame_{0}; + uint32_t last_init_attempt_{0}; + std::array rx_buffer_{}; + uint8_t rx_index_{0}; + uint8_t last_error_code_{0xFF}; + bool active_mode_{true}; + bool idle_{false}; + bool gas_warming_up_{false}; +}; + +} // namespace esphome::apc1 diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py new file mode 100644 index 0000000000..b64a49903f --- /dev/null +++ b/esphome/components/apc1/sensor.py @@ -0,0 +1,222 @@ +from esphome import automation, pins +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import sensor, uart +from esphome.components.const import CONF_AQI +import esphome.config_validation as cv +from esphome.const import ( + CONF_ECO2, + CONF_HUMIDITY, + CONF_ID, + CONF_PM_0_3UM, + CONF_PM_0_5UM, + CONF_PM_1_0, + CONF_PM_1_0_STD, + CONF_PM_1_0UM, + CONF_PM_2_5, + CONF_PM_2_5_STD, + CONF_PM_2_5UM, + CONF_PM_5_0UM, + CONF_PM_10_0, + CONF_PM_10_0_STD, + CONF_PM_10_0UM, + CONF_RESET_PIN, + CONF_TEMPERATURE, + CONF_TVOC, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_CHEMICAL_WEAPON, + ICON_MOLECULE_CO2, + ICON_OMEGA, + ICON_RADIATOR, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_COUNT_DECILITRE, + UNIT_MICROGRAMS_PER_CUBIC_METER, + UNIT_OHM, + UNIT_PARTS_PER_BILLION, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@ademuri"] +DEPENDENCIES = ["uart"] + +CONF_SET_PIN = "set_pin" +CONF_RAW_TEMPERATURE = "raw_temperature" +CONF_RAW_HUMIDITY = "raw_humidity" +CONF_RS0 = "rs0" +CONF_RS2 = "rs2" +CONF_RS3 = "rs3" +CONF_ERROR_CODE = "error_code" + +apc1_ns = cg.esphome_ns.namespace("apc1") +APC1Component = apc1_ns.class_("APC1Component", uart.UARTDevice, cg.Component) + + +def _pm_mass_schema(device_class: str) -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=device_class, + state_class=STATE_CLASS_MEASUREMENT, + ) + + +_PM_COUNT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNT_DECILITRE, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) +_TEMPERATURE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) +_HUMIDITY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, +) +_RESISTANCE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_OHM, + icon=ICON_OMEGA, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(APC1Component), + cv.Optional(CONF_PM_1_0): _pm_mass_schema(DEVICE_CLASS_PM1), + cv.Optional(CONF_PM_2_5): _pm_mass_schema(DEVICE_CLASS_PM25), + cv.Optional(CONF_PM_10_0): _pm_mass_schema(DEVICE_CLASS_PM10), + cv.Optional(CONF_PM_1_0_STD): _pm_mass_schema(DEVICE_CLASS_PM1), + cv.Optional(CONF_PM_2_5_STD): _pm_mass_schema(DEVICE_CLASS_PM25), + cv.Optional(CONF_PM_10_0_STD): _pm_mass_schema(DEVICE_CLASS_PM10), + cv.Optional(CONF_PM_0_3UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_0_5UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_1_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_2_5UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_5_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_10_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_TVOC): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ECO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_AQI): sensor.sensor_schema( + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): _TEMPERATURE_SCHEMA, + cv.Optional(CONF_HUMIDITY): _HUMIDITY_SCHEMA, + cv.Optional(CONF_RAW_TEMPERATURE): _TEMPERATURE_SCHEMA, + cv.Optional(CONF_RAW_HUMIDITY): _HUMIDITY_SCHEMA, + cv.Optional(CONF_RS0): _RESISTANCE_SCHEMA, + cv.Optional(CONF_RS2): _RESISTANCE_SCHEMA, + cv.Optional(CONF_RS3): _RESISTANCE_SCHEMA, + cv.Optional(CONF_ERROR_CODE): sensor.sensor_schema( + icon="mdi:alert-circle-outline", + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +def final_validate(config: ConfigType) -> None: + schema = uart.final_validate_device_schema( + "apc1", baud_rate=9600, require_rx=True, require_tx=True + ) + schema(config) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +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) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor) + await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor) + await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor) + await sensors(CONF_PM_0_3UM, var.set_pm_0_3um_sensor) + await sensors(CONF_PM_0_5UM, var.set_pm_0_5um_sensor) + await sensors(CONF_PM_1_0UM, var.set_pm_1_0um_sensor) + await sensors(CONF_PM_2_5UM, var.set_pm_2_5um_sensor) + await sensors(CONF_PM_5_0UM, var.set_pm_5_0um_sensor) + await sensors(CONF_PM_10_0UM, var.set_pm_10_0um_sensor) + await sensors(CONF_TVOC, var.set_tvoc_sensor) + await sensors(CONF_ECO2, var.set_eco2_sensor) + await sensors(CONF_AQI, var.set_aqi_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_RAW_TEMPERATURE, var.set_raw_temperature_sensor) + await sensors(CONF_RAW_HUMIDITY, var.set_raw_humidity_sensor) + await sensors(CONF_RS0, var.set_rs0_sensor) + await sensors(CONF_RS2, var.set_rs2_sensor) + await sensors(CONF_RS3, var.set_rs3_sensor) + await sensors(CONF_ERROR_CODE, var.set_error_code_sensor) + + if (set_pin_config := config.get(CONF_SET_PIN)) is not None: + set_pin = await cg.gpio_pin_expression(set_pin_config) + cg.add(var.set_set_pin(set_pin)) + + if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None: + reset_pin = await cg.gpio_pin_expression(reset_pin_config) + cg.add(var.set_reset_pin(reset_pin)) + + +APC1_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(): cv.use_id(APC1Component), + } +) + + +for _name, _call in ( + ("apc1.set_active_mode", "set_active_mode()"), + ("apc1.set_passive_mode", "set_passive_mode()"), + ("apc1.request_measurement", "request_measurement()"), + ("apc1.set_idle_mode", "set_idle_mode()"), + ("apc1.set_measurement_mode", "set_measurement_mode()"), +): + automation.register_apply_action( + _name, APC1_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/apc1/common.yaml b/tests/components/apc1/common.yaml new file mode 100644 index 0000000000..496f587e42 --- /dev/null +++ b/tests/components/apc1/common.yaml @@ -0,0 +1,62 @@ +sensor: + - platform: apc1 + id: apc1_sensor + set_pin: ${set_pin} + reset_pin: ${reset_pin} + pm_1_0: + name: "APC1 PM1.0" + pm_2_5: + name: "APC1 PM2.5" + filters: + - throttle_average: 10s + pm_10_0: + name: "APC1 PM10" + pm_1_0_std: + name: "APC1 PM1.0 Std" + pm_2_5_std: + name: "APC1 PM2.5 Std" + pm_10_0_std: + name: "APC1 PM10 Std" + pm_0_3um: + name: "APC1 Particles >0.3um" + pm_0_5um: + name: "APC1 Particles >0.5um" + pm_1_0um: + name: "APC1 Particles >1.0um" + pm_2_5um: + name: "APC1 Particles >2.5um" + pm_5_0um: + name: "APC1 Particles >5.0um" + pm_10_0um: + name: "APC1 Particles >10um" + tvoc: + name: "APC1 TVOC" + eco2: + name: "APC1 eCO2" + aqi: + name: "APC1 AQI" + temperature: + name: "APC1 Temperature" + humidity: + name: "APC1 Humidity" + raw_temperature: + name: "APC1 Raw Temperature" + raw_humidity: + name: "APC1 Raw Humidity" + rs0: + name: "APC1 RS0" + rs2: + name: "APC1 MOX RS2" + rs3: + name: "APC1 MOX RS3" + error_code: + name: "APC1 Error Code" + +interval: + - interval: 60s + then: + - apc1.set_active_mode: apc1_sensor + - apc1.set_passive_mode: apc1_sensor + - apc1.request_measurement: apc1_sensor + - apc1.set_idle_mode: apc1_sensor + - apc1.set_measurement_mode: apc1_sensor diff --git a/tests/components/apc1/test.esp32-idf.yaml b/tests/components/apc1/test.esp32-idf.yaml new file mode 100644 index 0000000000..a8fdc5956d --- /dev/null +++ b/tests/components/apc1/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO32 + reset_pin: GPIO14 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + apc1: !include common.yaml diff --git a/tests/components/apc1/test.esp8266-ard.yaml b/tests/components/apc1/test.esp8266-ard.yaml new file mode 100644 index 0000000000..746829bea7 --- /dev/null +++ b/tests/components/apc1/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO4 + reset_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + apc1: !include common.yaml diff --git a/tests/components/apc1/test.rp2040-ard.yaml b/tests/components/apc1/test.rp2040-ard.yaml new file mode 100644 index 0000000000..e1dc80743f --- /dev/null +++ b/tests/components/apc1/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO2 + reset_pin: GPIO3 + +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + apc1: !include common.yaml From 331d12af2f228ea6be18736c7f9f9604458e52e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:39:10 -1000 Subject: [PATCH 238/268] [binary_sensor] Share multi click timing tables in flash and skip the default cooldown setter (#20071) --- esphome/components/binary_sensor/__init__.py | 23 ++++++---- .../components/binary_sensor/automation.cpp | 27 ++++++------ esphome/components/binary_sensor/automation.h | 42 +++++++------------ .../binary_sensor/test_multi_click.py | 29 +++++++++++++ .../binary_sensor/test_multi_click.yaml | 33 +++++++++++++++ 5 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 tests/component_tests/binary_sensor/test_multi_click.py create mode 100644 tests/component_tests/binary_sensor/test_multi_click.yaml diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 109a22c7bc..1384f8b008 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -74,6 +74,10 @@ from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] + +DEFAULT_INVALID_COOLDOWN_MS = ( + 1000 # Keep in sync with invalid_cooldown_ in automation.h +) DEVICE_CLASSES = [ DEVICE_CLASS_BATTERY, DEVICE_CLASS_BATTERY_CHARGING, @@ -131,10 +135,9 @@ ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.templa DoubleClickTrigger = binary_sensor_ns.class_( "DoubleClickTrigger", automation.Trigger.template() ) -MultiClickTriggerBase = binary_sensor_ns.class_( - "MultiClickTriggerBase", automation.Trigger.template(), cg.Component +MultiClickTrigger = binary_sensor_ns.class_( + "MultiClickTrigger", automation.Trigger.template(), cg.Component ) -MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") @@ -492,7 +495,8 @@ _BINARY_SENSOR_SCHEMA = ( cv.Length(min=1, max=255), ), cv.Optional( - CONF_INVALID_COOLDOWN, default="1s" + CONF_INVALID_COOLDOWN, + default=f"{DEFAULT_INVALID_COOLDOWN_MS}ms", ): cv.positive_time_period_milliseconds, } ), @@ -586,11 +590,14 @@ async def _build_binary_sensor_automations(var, config): ) for tim in conf[CONF_TIMING] ] - trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], cg.TemplateArguments(len(timings)), var, timings + table = cg.shared_progmem_array( + "multi_click_timing", MultiClickTriggerEvent, cg.ArrayInitializer(*timings) ) - if CONF_INVALID_COOLDOWN in conf: - cg.add(trigger.set_invalid_cooldown(conf[CONF_INVALID_COOLDOWN])) + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, table, len(timings)) + if ( + cooldown := conf[CONF_INVALID_COOLDOWN] + ).total_milliseconds != DEFAULT_INVALID_COOLDOWN_MS: + cg.add(trigger.set_invalid_cooldown(cooldown)) await cg.register_component(trigger, conf) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index d4b4906c30..46f7d9488c 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -18,7 +18,7 @@ constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1; constexpr uint32_t MULTICLICK_IS_VALID_ID = 2; constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3; -void MultiClickTriggerBase::on_state_(bool state) { +void MultiClickTrigger::on_state_(bool state) { // Handle duplicate events if (state == this->last_state_) { return; @@ -30,7 +30,7 @@ void MultiClickTriggerBase::on_state_(bool state) { return; } - if (!this->at_index_.has_value()) { + if (this->at_index_ == 0) { // Start matching MultiClickTriggerEvent evt = this->timing_[0]; if (evt.state == state) { @@ -55,8 +55,7 @@ void MultiClickTriggerBase::on_state_(bool state) { return; } - // at_index_ has a value here (the !has_value() branch above returns). - size_t at_index = *this->at_index_; + uint8_t at_index = this->at_index_; if (at_index == this->timing_count_) { this->trigger_(); return; @@ -65,15 +64,15 @@ void MultiClickTriggerBase::on_state_(bool state) { MultiClickTriggerEvent evt = this->timing_[at_index]; if (evt.max_length != 4294967294UL) { - ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); // NOLINT + ESP_LOGV(TAG, "A i=%u min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); this->schedule_is_valid_(evt.min_length); this->schedule_is_not_valid_(evt.max_length); } else if (at_index + 1 != this->timing_count_) { - ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT + ESP_LOGV(TAG, "B i=%u min=%" PRIu32, at_index, evt.min_length); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->schedule_is_valid_(evt.min_length); } else { - ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT + ESP_LOGV(TAG, "C i=%u min=%" PRIu32, at_index, evt.min_length); this->is_valid_ = false; this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); @@ -81,19 +80,19 @@ void MultiClickTriggerBase::on_state_(bool state) { this->at_index_ = at_index + 1; } -void MultiClickTriggerBase::schedule_cooldown_() { +void MultiClickTrigger::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); this->is_in_cooldown_ = true; this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() { ESP_LOGV(TAG, "Multi Click: Cooldown ended, matching is now enabled again."); this->is_in_cooldown_ = false; }); - this->at_index_.reset(); + this->at_index_ = 0; this->cancel_timeout(MULTICLICK_TRIGGER_ID); this->cancel_timeout(MULTICLICK_IS_VALID_ID); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); } -void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { +void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { if (min_length == 0) { this->is_valid_ = true; return; @@ -105,7 +104,7 @@ void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { this->is_valid_ = true; }); } -void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { +void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() { ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? LOG_STR_LITERAL("RELEASE") : LOG_STR_LITERAL("PRESS")); @@ -113,14 +112,14 @@ void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { this->schedule_cooldown_(); }); } -void MultiClickTriggerBase::cancel() { +void MultiClickTrigger::cancel() { ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled."); this->is_valid_ = false; this->schedule_cooldown_(); } -void MultiClickTriggerBase::trigger_() { +void MultiClickTrigger::trigger_() { ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!"); - this->at_index_.reset(); + this->at_index_ = 0; this->cancel_timeout(MULTICLICK_TRIGGER_ID); this->cancel_timeout(MULTICLICK_IS_VALID_ID); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 49457200bc..7426cc5614 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include "esphome/core/component.h" @@ -13,10 +13,14 @@ namespace esphome::binary_sensor { struct MultiClickTriggerEvent { - bool state; + uint32_t state; // Not bool: all-word fields allow aligned loads from PROGMEM on ESP8266 uint32_t min_length; uint32_t max_length; }; +// Read straight from PROGMEM; a byte-sized field would fault on ESP8266. +static_assert(std::is_same_v && + sizeof(MultiClickTriggerEvent) == 3 * sizeof(uint32_t), + "MultiClickTriggerEvent fields must all be uint32_t"); bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); @@ -70,10 +74,11 @@ class DoubleClickTrigger final : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -/// Non-template base for MultiClickTrigger (keeps large method bodies out of the header). -class MultiClickTriggerBase : public Trigger<>, public Component { +class MultiClickTrigger final : public Trigger<>, public Component { public: - explicit MultiClickTriggerBase(BinarySensor *parent) : parent_(parent) {} + /// `timing` is a codegen PROGMEM table shared by triggers with the same timing. + MultiClickTrigger(BinarySensor *parent, const MultiClickTriggerEvent *timing, uint8_t timing_count) + : parent_(parent), timing_(timing), timing_count_(timing_count) {} void setup() override { this->last_state_ = this->parent_->get_state_default(false); @@ -85,8 +90,8 @@ class MultiClickTriggerBase : public Trigger<>, public Component { void set_invalid_cooldown(uint32_t invalid_cooldown) { this->invalid_cooldown_ = invalid_cooldown; } void cancel(); - MultiClickTriggerBase(const MultiClickTriggerBase &) = delete; - MultiClickTriggerBase &operator=(const MultiClickTriggerBase &) = delete; + MultiClickTrigger(const MultiClickTrigger &) = delete; + MultiClickTrigger &operator=(const MultiClickTrigger &) = delete; protected: void on_state_(bool state); @@ -96,28 +101,13 @@ class MultiClickTriggerBase : public Trigger<>, public Component { void trigger_(); BinarySensor *parent_; - const MultiClickTriggerEvent *timing_{nullptr}; - uint32_t invalid_cooldown_{1000}; - optional at_index_{}; - uint8_t timing_count_{0}; + const MultiClickTriggerEvent *timing_; + uint32_t invalid_cooldown_{1000}; // Must match DEFAULT_INVALID_COOLDOWN_MS in __init__.py + uint8_t timing_count_; + uint8_t at_index_{0}; // 0: not matching, otherwise the index of the next timing event bool last_state_{false}; bool is_in_cooldown_{false}; bool is_valid_{false}; }; -/// Template wrapper that provides inline std::array storage for timing events. -/// N is set by code generation to match the exact number of timing events configured in YAML. -template class MultiClickTrigger final : public MultiClickTriggerBase { - public: - MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) - : MultiClickTriggerBase(parent) { - init_array_from(this->timing_storage_, timing); - this->timing_ = this->timing_storage_.data(); - this->timing_count_ = N; - } - - protected: - std::array timing_storage_{}; -}; - } // namespace esphome::binary_sensor diff --git a/tests/component_tests/binary_sensor/test_multi_click.py b/tests/component_tests/binary_sensor/test_multi_click.py new file mode 100644 index 0000000000..6f2556735f --- /dev/null +++ b/tests/component_tests/binary_sensor/test_multi_click.py @@ -0,0 +1,29 @@ +"""Tests for the multi click trigger codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_multi_click_timing_tables(generate_main: Callable[[str | Path], str]) -> None: + """Identical timings share one PROGMEM table; the default cooldown is not set.""" + main_cpp = generate_main( + "tests/component_tests/binary_sensor/test_multi_click.yaml" + ) + + tables = re.findall( + r"static constexpr binary_sensor::MultiClickTriggerEvent (\w+)\[\] PROGMEM", + main_cpp, + ) + assert len(tables) == 2 + single, long_press = tables + triggers = re.findall( + r"binary_sensor::MultiClickTrigger\((\w+), (\w+), (\d+)\);", main_cpp + ) + assert triggers == [ + ("button_a", single, "2"), + ("button_a", long_press, "1"), + ("button_b", single, "2"), + ] + assert main_cpp.count("set_invalid_cooldown(") == 1 + assert "set_invalid_cooldown(500);" in main_cpp diff --git a/tests/component_tests/binary_sensor/test_multi_click.yaml b/tests/component_tests/binary_sensor/test_multi_click.yaml new file mode 100644 index 0000000000..c18737ea3c --- /dev/null +++ b/tests/component_tests/binary_sensor/test_multi_click.yaml @@ -0,0 +1,33 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +binary_sensor: + - platform: gpio + id: button_a + pin: GPIO32 + on_multi_click: + - timing: + - ON for at most 1s + - OFF for at least 0.25s + then: + - logger.log: a single + - timing: + - ON for at least 2s + invalid_cooldown: 500ms + then: + - logger.log: a long + - platform: gpio + id: button_b + pin: GPIO33 + on_multi_click: + - timing: + - ON for at most 1s + - OFF for at least 0.25s + then: + - logger.log: b single From b66cde0270b37d0716ae7442f0aa3dc6a4540224 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:39:38 -1000 Subject: [PATCH 239/268] [sensor] Share calibrate_linear and calibrate_polynomial tables in flash (#20074) --- esphome/components/sensor/__init__.py | 15 ++++-- esphome/components/sensor/filter.cpp | 18 ++++--- esphome/components/sensor/filter.h | 34 ++++--------- .../sensor/test_calibrate.yaml | 51 +++++++++++++++++++ tests/component_tests/sensor/test_sensor.py | 27 ++++++++++ 5 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 tests/component_tests/sensor/test_calibrate.yaml diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 1440e07f76..8f345e3e66 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -806,6 +806,9 @@ def validate_calibrate_linear(config): return config +LINEAR_SEGMENT = cg.std_ns.class_("array").template(cg.float_, 3) + + @FILTER_REGISTRY.register( "calibrate_linear", CalibrateLinearFilter, @@ -832,9 +835,11 @@ async def calibrate_linear_filter_to_code(config, filter_id): linear_functions = [[k, b, float("NaN")]] elif config[CONF_METHOD] == "exact": linear_functions = map_linear(x, y) - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(linear_functions)), linear_functions + # The last segment always has a NaN boundary, so the filter needs no count. + table = cg.shared_progmem_array( + "sensor_calibrate_linear", LINEAR_SEGMENT, linear_functions ) + return cg.new_Pvariable(filter_id, table) CONF_DEGREE = "degree" @@ -872,7 +877,11 @@ async def calibrate_polynomial_filter_to_code(config, filter_id): # Column vector b = [[v] for v in y] res = [v[0] for v in _lstsq(a, b)] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(res)), res) + # The first entry holds the coefficient count, so the filter needs no count field. + table = cg.shared_progmem_array( + "sensor_calibrate_polynomial", cg.float_, [float(len(res)), *res] + ) + return cg.new_Pvariable(filter_id, table) def validate_clamp(config): diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 732db5b0b4..ed48ddebdb 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -415,19 +415,21 @@ void HeartbeatFilter::initialize(Sensor *parent, Filter *next) { }); } -optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { - for (size_t i = 0; i < count; i++) { - if (!std::isfinite(functions[i][2]) || value < functions[i][2]) - return (value * functions[i][0]) + functions[i][1]; +// The calibration tables hold only floats, so aligned 32-bit loads from PROGMEM are safe on ESP8266. +optional CalibrateLinearFilter::new_value(float value) { + // Always returns: codegen ends the table with a segment whose boundary is NaN. + for (const auto *function = this->functions_;; function++) { + if (!std::isfinite((*function)[2]) || value < (*function)[2]) + return (value * (*function)[0]) + (*function)[1]; } - return NAN; } -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value) { +optional CalibratePolynomialFilter::new_value(float value) { float res = 0.0f; float x = 1.0f; - for (size_t i = 0; i < count; i++) { - res += x * coefficients[i]; + const auto count = static_cast(this->coefficients_[0]); + for (size_t i = 1; i <= count; i++) { + res += x * this->coefficients_[i]; x *= value; } return res; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 01949f45df..9b5ef49308 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -579,38 +579,24 @@ template class OrFilter : public Filter { bool has_value_{false}; }; -/// Non-template helper for linear calibration (implementation in filter.cpp) -optional calibrate_linear_compute(const std::array *functions, size_t count, float value); - -/// N is set by code generation to match the exact number of calibration segments. -template class CalibrateLinearFilter : public Filter { +/// Piecewise linear calibration; the segments are a PROGMEM table shared by filters with the same data. +class CalibrateLinearFilter : public Filter { public: - explicit CalibrateLinearFilter(std::initializer_list> linear_functions) { - init_array_from(this->linear_functions_, linear_functions); - } - optional new_value(float value) override { - return calibrate_linear_compute(this->linear_functions_.data(), N, value); - } + explicit CalibrateLinearFilter(const std::array *functions) : functions_(functions) {} + optional new_value(float value) override; protected: - std::array, N> linear_functions_{}; + const std::array *functions_; // Last segment has a NaN boundary, which ends the table }; -/// Non-template helper for polynomial calibration (implementation in filter.cpp) -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value); - -/// N is set by code generation to match the exact number of polynomial coefficients. -template class CalibratePolynomialFilter : public Filter { +/// Polynomial calibration; the coefficients are a PROGMEM table shared by filters with the same data. +class CalibratePolynomialFilter : public Filter { public: - explicit CalibratePolynomialFilter(std::initializer_list coefficients) { - init_array_from(this->coefficients_, coefficients); - } - optional new_value(float value) override { - return calibrate_polynomial_compute(this->coefficients_.data(), N, value); - } + explicit CalibratePolynomialFilter(const float *coefficients) : coefficients_(coefficients) {} + optional new_value(float value) override; protected: - std::array coefficients_{}; + const float *coefficients_; // [count, c0, c1, ...] }; class ClampFilter : public Filter { diff --git a/tests/component_tests/sensor/test_calibrate.yaml b/tests/component_tests/sensor/test_calibrate.yaml new file mode 100644 index 0000000000..41a87c8779 --- /dev/null +++ b/tests/component_tests/sensor/test_calibrate.yaml @@ -0,0 +1,51 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +sensor: + - platform: template + id: exact_a + lambda: return 1.0; + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + - platform: template + id: exact_b + lambda: return 1.0; + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + - platform: template + id: least_squares + lambda: return 1.0; + filters: + - calibrate_linear: + - 0.0 -> 1.0 + - 10.0 -> 21.0 + - platform: template + id: polynomial + lambda: return 1.0; + filters: + - calibrate_polynomial: + degree: 1 + datapoints: + - 0.0 -> 1.0 + - 10.0 -> 21.0 + # User ids equal to the old derived table name and to the helper's base name. + - platform: template + id: sensor_calibratelinearfilter_id_data + lambda: return 1.0; + - platform: template + id: sensor_calibrate_linear + lambda: return 1.0; diff --git a/tests/component_tests/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index 9d18fa36b8..07981c1897 100644 --- a/tests/component_tests/sensor/test_sensor.py +++ b/tests/component_tests/sensor/test_sensor.py @@ -1,5 +1,8 @@ """Tests for the sensor component.""" +from collections.abc import Callable +import re + from tests.component_tests.helpers import extract_packed_value @@ -15,3 +18,27 @@ def test_sensor_device_class_set(generate_main): # Then: device_class: voltage means packed value must be non-zero packed = extract_packed_value(main_cpp, "s_1") assert packed != 0 + + +def test_calibration_tables_are_shared_progmem( + generate_main: Callable[[str], str], +) -> None: + """Calibration data is a PROGMEM table, shared by filters with identical data.""" + main_cpp = generate_main("tests/component_tests/sensor/test_calibrate.yaml") + + linear = re.findall( + r"static constexpr std::array (\w+)\[\] PROGMEM", main_cpp + ) + assert len(linear) == 2 # the two exact filters share one table + exact, least_squares = linear + assert main_cpp.count(f"sensor::CalibrateLinearFilter({exact});") == 2 + assert f"sensor::CalibrateLinearFilter({least_squares});" in main_cpp + + poly = re.search( + r"static constexpr float (\w+)\[\] PROGMEM = \{2\.0f, 1\.0f, 2\.0f\};", main_cpp + ) + assert poly is not None + assert f"sensor::CalibratePolynomialFilter({poly.group(1)});" in main_cpp + # Table names never reuse a user id. + assert exact != "sensor_calibrate_linear" + assert "sensor_calibratelinearfilter_id_data" not in (exact, least_squares) From 3ad83ad02bf868c696f4b37441e289885018d0c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:40:38 -1000 Subject: [PATCH 240/268] [text_sensor] Share substitute and map filter tables in flash (#20076) --- esphome/components/text_sensor/__init__.py | 36 ++++++++---------- esphome/components/text_sensor/filter.cpp | 24 ++++++------ esphome/components/text_sensor/filter.h | 34 ++++++----------- .../text_sensor/test_text_sensor.py | 38 +++++++++++++++++++ ...est_text_sensor_filter_table_id_clash.yaml | 18 +++++++++ .../test_text_sensor_filter_tables.yaml | 25 ++++++++++++ 6 files changed, 121 insertions(+), 54 deletions(-) create mode 100644 tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml create mode 100644 tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 959ce77c7a..2ff0421f39 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -59,6 +59,7 @@ AppendFilter = text_sensor_ns.class_("AppendFilter", Filter) PrependFilter = text_sensor_ns.class_("PrependFilter", Filter) SubstituteFilter = text_sensor_ns.class_("SubstituteFilter", Filter) MapFilter = text_sensor_ns.class_("MapFilter", Filter) +Substitution = text_sensor_ns.struct("Substitution") @FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda) @@ -102,34 +103,29 @@ def validate_mapping(value): )(value) -@FILTER_REGISTRY.register( - "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) -) -async def substitute_filter_to_code(config, filter_id): - substitutions = [ +def _substitution_table(config: list[ConfigType]) -> MockObj: + """Shared PROGMEM Substitution table, ended by an empty pair so filters store no count.""" + pairs = [ cg.StructInitializer( - cg.MockObj("Substitution", "esphome::text_sensor::"), - ("from", conf[CONF_FROM]), - ("to", conf[CONF_TO]), + Substitution, ("from", conf[CONF_FROM]), ("to", conf[CONF_TO]) ) for conf in config ] - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(substitutions)), substitutions + end = cg.RawExpression("nullptr") + pairs.append(cg.StructInitializer(Substitution, ("from", end), ("to", end))) + return cg.shared_progmem_array( + "text_sensor_substitutions", Substitution, cg.ArrayInitializer(*pairs) ) +@FILTER_REGISTRY.register( + "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) +) @FILTER_REGISTRY.register("map", MapFilter, cv.ensure_list(validate_mapping)) -async def map_filter_to_code(config, filter_id): - mappings = [ - cg.StructInitializer( - cg.MockObj("Substitution", "esphome::text_sensor::"), - ("from", conf[CONF_FROM]), - ("to", conf[CONF_TO]), - ) - for conf in config - ] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(mappings)), mappings) +async def substitution_filter_to_code( + config: list[ConfigType], filter_id: ID +) -> MockObj: + return cg.new_Pvariable(filter_id, _substitution_table(config)) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index 9d18fb9b1a..54457d8238 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -73,25 +73,25 @@ bool PrependFilter::new_value(std::string &value) { return true; } -// Substitute — non-template helper -bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value) { - for (size_t i = 0; i < count; i++) { - const size_t from_len = strlen(substitutions[i].from); - const size_t to_len = strlen(substitutions[i].to); +// Substitute +bool SubstituteFilter::new_value(std::string &value) { + for (const Substitution *s = this->substitutions_; s->from != nullptr; s++) { + const size_t from_len = strlen(s->from); + const size_t to_len = strlen(s->to); std::size_t pos = 0; - while ((pos = value.find(substitutions[i].from, pos, from_len)) != std::string::npos) { - value.replace(pos, from_len, substitutions[i].to, to_len); + while ((pos = value.find(s->from, pos, from_len)) != std::string::npos) { + value.replace(pos, from_len, s->to, to_len); pos += to_len; } } return true; } -// Map — non-template helper -bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value) { - for (size_t i = 0; i < count; i++) { - if (value == mappings[i].from) { - value.assign(mappings[i].to); +// Map +bool MapFilter::new_value(std::string &value) { + for (const Substitution *m = this->mappings_; m->from != nullptr; m++) { + if (value == m->from) { + value.assign(m->to); return true; } } diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 6db76dcb64..7898d46686 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -3,8 +3,6 @@ #include "esphome/core/defines.h" #ifdef USE_TEXT_SENSOR_FILTER -#include - #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -123,25 +121,18 @@ struct Substitution { const char *to; }; -/// Non-template helper (implementation in filter.cpp) -bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value); - /// A simple filter that replaces a substring with another substring. -/// N is set by code generation to match the exact number of substitutions configured in YAML. -template class SubstituteFilter : public Filter { +/// `substitutions` is a codegen table in flash, shared by filters with the same list and +/// ended by an entry whose `from` is nullptr, so no count is stored. +class SubstituteFilter : public Filter { public: - explicit SubstituteFilter(const std::initializer_list &substitutions) { - init_array_from(this->substitutions_, substitutions); - } - bool new_value(std::string &value) override { return substitute_filter_apply(this->substitutions_.data(), N, value); } + explicit SubstituteFilter(const Substitution *substitutions) : substitutions_(substitutions) {} + bool new_value(std::string &value) override; protected: - std::array substitutions_{}; + const Substitution *substitutions_; }; -/// Non-template helper (implementation in filter.cpp) -bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value); - /** A filter that maps values from one set to another * * Uses linear search instead of std::map for typical small datasets (2-20 mappings). @@ -166,17 +157,16 @@ bool map_filter_apply(const Substitution *mappings, size_t count, std::string &v * * Break-even point: ~35-40 mappings, but ESPHome configs rarely exceed 20 * - * N is set by code generation to match the exact number of mappings configured in YAML. + * `mappings` is a codegen table in flash, shared by filters with the same list and ended by + * an entry whose `from` is nullptr, so no count is stored. */ -template class MapFilter : public Filter { +class MapFilter : public Filter { public: - explicit MapFilter(const std::initializer_list &mappings) { - init_array_from(this->mappings_, mappings); - } - bool new_value(std::string &value) override { return map_filter_apply(this->mappings_.data(), N, value); } + explicit MapFilter(const Substitution *mappings) : mappings_(mappings) {} + bool new_value(std::string &value) override; protected: - std::array mappings_{}; + const Substitution *mappings_; }; } // namespace esphome::text_sensor diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index eb25af3095..9eda24f7b9 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -1,5 +1,8 @@ """Tests for the text sensor component.""" +from collections.abc import Callable +import re + from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value @@ -62,3 +65,38 @@ def test_text_sensor_device_class_set(generate_main): assert packed_ts_2 != 0 packed_ts_3 = extract_packed_value(main_cpp, "ts_3") assert packed_ts_3 != 0 + + +def test_substitution_filters_share_progmem_tables( + generate_main: Callable[[str], str], +) -> None: + """Map and substitute pairs live in PROGMEM tables; equal lists share one.""" + main_cpp = generate_main( + "tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml" + ) + + tables = re.findall( + r"static constexpr text_sensor::Substitution (\w+)\[\] PROGMEM", main_cpp + ) + assert len(tables) == 2 + map_table, sub_table = tables + assert main_cpp.count(f"text_sensor::MapFilter({map_table})") == 2 + assert f"text_sensor::SubstituteFilter({sub_table})" in main_cpp + # Each table ends with an empty pair so the filters store no count. + assert main_cpp.count(".from = nullptr") == 2 + + +def test_substitution_table_names_avoid_user_ids( + generate_main: Callable[[str], str], +) -> None: + """A user id equal to a table name cannot collide with the generated table.""" + main_cpp = generate_main( + "tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml" + ) + + tables = re.findall( + r"static constexpr text_sensor::Substitution (\w+)\[\] PROGMEM", main_cpp + ) + assert len(tables) == 2 + assert "text_sensor_substitutions" not in tables + assert "text_sensor_mapfilter_id_table" not in tables diff --git a/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml b/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml new file mode 100644 index 0000000000..d0526cab19 --- /dev/null +++ b/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +text_sensor: + - platform: template + id: text_sensor_substitutions + filters: + - map: + - "ON -> Open" + - platform: template + id: text_sensor_mapfilter_id_table + filters: + - map: + - "OFF -> Closed" diff --git a/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml b/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml new file mode 100644 index 0000000000..ac422c3309 --- /dev/null +++ b/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +text_sensor: + - platform: template + id: ts_map_a + filters: + - map: + - "ON -> Open" + - "OFF -> Closed" + - platform: template + id: ts_map_b + filters: + - map: + - "ON -> Open" + - "OFF -> Closed" + - platform: template + id: ts_sub + filters: + - substitute: + - "foo -> bar" From 80fa6efd1e2fd117b2f5ae601fc60fe56b3661ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:44:49 -1000 Subject: [PATCH 241/268] [core] Document string tables, shared CRC helpers, NaN sentinels and polling start-up in AGENTS.md (#20599) --- AGENTS.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6bdd9467c0..5031c4a457 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,21 @@ file does, and it is the authority when they disagree. The most useful starting `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. + * **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`), + logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`. + A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`. + * **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`); + pick the helper's polynomial and bit-order arguments instead of writing a CRC loop. + * **An unset float is `NAN`**, not `optional`; the optional costs 4 to 8 bytes of padding per member. + * **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the + end of `setup()` when the entities need a state at boot. + * **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing + resend and give-up logic handles it; never advance on an answer that was only logged. + * **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python + tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual + listener base (a second vptr per entity). + * **A global feature define** says the code is compiled in, not that this instance uses it: keep the + per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`. * **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 @@ -589,7 +604,9 @@ file does, and it is the authority when they disagree. The most useful starting `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`). + timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its + `responses` match on content every time they see it; to answer a repeated request differently, use an + `on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`). * **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: From 8aab968136acb4867fc170c3eb4eab03c48759a2 Mon Sep 17 00:00:00 2001 From: Patrick O'Doherty Date: Mon, 12 Oct 2026 17:11:10 -0700 Subject: [PATCH 242/268] [aranet] Add Aranet BLE sensor support (#18263) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/aranet/__init__.py | 2 + esphome/components/aranet/aranet.cpp | 157 ++++++++++++++++++ esphome/components/aranet/aranet.h | 42 +++++ esphome/components/aranet/sensor.py | 150 +++++++++++++++++ tests/components/aranet/common-ln.yaml | 7 + tests/components/aranet/common.h | 70 ++++++++ tests/components/aranet/common.yaml | 27 +++ .../components/aranet/sensor/test_aranet.cpp | 144 ++++++++++++++++ tests/components/aranet/test.esp32-idf.yaml | 3 + tests/components/aranet/test.ln882x-ard.yaml | 3 + .../aranet/validate.bk72xx-ard.yaml | 12 ++ 12 files changed, 618 insertions(+) create mode 100644 esphome/components/aranet/__init__.py create mode 100644 esphome/components/aranet/aranet.cpp create mode 100644 esphome/components/aranet/aranet.h create mode 100644 esphome/components/aranet/sensor.py create mode 100644 tests/components/aranet/common-ln.yaml create mode 100644 tests/components/aranet/common.h create mode 100644 tests/components/aranet/common.yaml create mode 100644 tests/components/aranet/sensor/test_aranet.cpp create mode 100644 tests/components/aranet/test.esp32-idf.yaml create mode 100644 tests/components/aranet/test.ln882x-ard.yaml create mode 100644 tests/components/aranet/validate.bk72xx-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index fab41f5b1f..075b8113d8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -44,6 +44,7 @@ esphome/components/apds9306/* @aodrenah esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core esphome/components/aqi/* @freekode @jasstrong @ximex +esphome/components/aranet/* @patrickod esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr diff --git a/esphome/components/aranet/__init__.py b/esphome/components/aranet/__init__.py new file mode 100644 index 0000000000..d1d75568e9 --- /dev/null +++ b/esphome/components/aranet/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@patrickod"] +DOMAIN = "aranet" diff --git a/esphome/components/aranet/aranet.cpp b/esphome/components/aranet/aranet.cpp new file mode 100644 index 0000000000..4fa28eec17 --- /dev/null +++ b/esphome/components/aranet/aranet.cpp @@ -0,0 +1,157 @@ +#include "aranet.h" + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::aranet { + +ESPHOME_LOG_TAG(TAG, "aranet"); +static constexpr uint16_t ARANET_MANUFACTURER_ID = 0x0702; +static constexpr size_t ARANET4_DATA_LENGTH = 22; +static constexpr size_t OTHER_ARANET_DATA_LENGTH = 24; + +enum class AranetDeviceType : uint8_t { + ARANET_DEVICE_TYPE_ARANET4 = 0, + ARANET_DEVICE_TYPE_ARANET2 = 1, + ARANET_DEVICE_TYPE_RADIATION = 2, + ARANET_DEVICE_TYPE_RADON = 3, +}; + +static inline void publish_if_set(sensor::Sensor *sensor, float value) { + if (sensor != nullptr) + sensor->publish_state(value); +} + +// Aranet does not publish an official advertisement specification. The layout below is corroborated by these +// independently maintained implementations: +// https://github.com/Anrijs/Aranet4-Python/blob/master/aranet4/client.py +// https://github.com/Anrijs/Aranet4-ESP32/blob/main/src/Aranet4.h + +void Aranet::dump_config() { + ESP_LOGCONFIG(TAG, "Aranet"); + LOG_SENSOR(" ", "Carbon Dioxide", this->co2_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Measurement Interval", this->measurement_interval_sensor_); + LOG_SENSOR(" ", "Measurement Age", this->measurement_age_sensor_); + LOG_SENSOR(" ", "Signal Strength", this->signal_strength_sensor_); + LOG_SENSOR(" ", "Radon Concentration", this->radon_sensor_); + LOG_SENSOR(" ", "Radiation Dose Rate", this->radiation_rate_sensor_); + LOG_SENSOR(" ", "Radiation Total Dose", this->radiation_total_sensor_); + LOG_SENSOR(" ", "Radiation Dose Duration", this->radiation_duration_sensor_); +} + +bool Aranet::parse_device(const ble_device_base::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) + return false; + + const auto manufacturer_uuid = ble_device_base::ESPBTUUID::from_uint16(ARANET_MANUFACTURER_ID); + for (const auto &manufacturer_data : device.get_manufacturer_datas()) { + if (manufacturer_data.uuid != manufacturer_uuid) + continue; + + const auto &data = manufacturer_data.data; + if (data.size() != ARANET4_DATA_LENGTH && data.size() != OTHER_ARANET_DATA_LENGTH) { + if ((this->warned_ & WARNED_INVALID_LENGTH) == 0) { + ESP_LOGW(TAG, "Unexpected manufacturer data length: %u", static_cast(data.size())); + this->warned_ |= WARNED_INVALID_LENGTH; + } + return false; + } + + // Aranet4 has no device type byte, so its flags and trailer start one byte earlier + const bool is_aranet4 = data.size() == ARANET4_DATA_LENGTH; + if ((data[is_aranet4 ? 0 : 1] & 0x20) == 0) { + if ((this->warned_ & WARNED_INTEGRATION_DISABLED) == 0) { + ESP_LOGW(TAG, "Smart Home integration is disabled on the Aranet device"); + this->warned_ |= WARNED_INTEGRATION_DISABLED; + } + return false; + } + + if (!is_aranet4 && (data[0] == 0 || data[0] > 3)) { + if ((this->warned_ & WARNED_UNSUPPORTED_TYPE) == 0) { + ESP_LOGW(TAG, "Unsupported Aranet device type: %u", data[0]); + this->warned_ |= WARNED_UNSUPPORTED_TYPE; + } + return false; + } + const auto device_type = + is_aranet4 ? AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4 : static_cast(data[0]); + + // Battery, measurement interval, measurement age and measurement counter + const uint8_t *trailer = &data[is_aranet4 ? 15 : 17]; + const uint16_t measurement_interval = encode_uint16(trailer[3], trailer[2]); + const uint16_t measurement_age = encode_uint16(trailer[5], trailer[4]); + const uint8_t measurement_counter = trailer[6]; + + publish_if_set(this->measurement_age_sensor_, measurement_age); + publish_if_set(this->signal_strength_sensor_, device.get_rssi()); + + if (measurement_counter == this->last_measurement_counter_) + return true; + this->last_measurement_counter_ = measurement_counter; + + // Not meaningful for Aranet Radiation, which stores its dose duration in these bytes + const float temperature = encode_uint16(data[11], data[10]) / 20.0f; + const float pressure = encode_uint16(data[13], data[12]) / 10.0f; + switch (device_type) { + case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4: { + const uint16_t co2 = encode_uint16(data[9], data[8]); + publish_if_set(this->co2_sensor_, co2); + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->pressure_sensor_, pressure); + publish_if_set(this->humidity_sensor_, data[14]); + ESP_LOGD(TAG, "Aranet4 CO2: %u ppm, temperature: %.1f C, humidity: %u%%, pressure: %.1f hPa", co2, temperature, + data[14], pressure); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET2: { + const float humidity = encode_uint16(data[15], data[14]) / 10.0f; + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->humidity_sensor_, humidity); + ESP_LOGD(TAG, "Aranet2 temperature: %.1f C, humidity: %.1f%%", temperature, humidity); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_RADIATION: { + const uint32_t radiation_total = encode_uint32(data[9], data[8], data[7], data[6]); + const uint32_t radiation_duration = encode_uint32(data[13], data[12], data[11], data[10]); + const uint16_t radiation_rate = encode_uint16(data[15], data[14]); + publish_if_set(this->radiation_rate_sensor_, radiation_rate / 1000.0f); + publish_if_set(this->radiation_total_sensor_, radiation_total); + publish_if_set(this->radiation_duration_sensor_, radiation_duration); + ESP_LOGD(TAG, "Aranet Radiation dose rate: %u nSv/h, total dose: %" PRIu32 " nSv", radiation_rate, + radiation_total); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_RADON: { + const uint16_t radon = encode_uint16(data[9], data[8]); + const float humidity = encode_uint16(data[15], data[14]) / 10.0f; + publish_if_set(this->radon_sensor_, radon); + // Aranet Radon One uses zero temperature and humidity to indicate fields not supported by that model. + if (temperature != 0) + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->pressure_sensor_, pressure); + if (humidity != 0) + publish_if_set(this->humidity_sensor_, humidity); + ESP_LOGD(TAG, "Aranet Radon: %u Bq/m3, temperature: %.1f C, humidity: %.1f%%, pressure: %.1f hPa", radon, + temperature, humidity, pressure); + break; + } + default: + break; + } + publish_if_set(this->battery_level_sensor_, trailer[0]); + publish_if_set(this->measurement_interval_sensor_, measurement_interval); + + return true; + } + + return false; +} + +} // namespace esphome::aranet diff --git a/esphome/components/aranet/aranet.h b/esphome/components/aranet/aranet.h new file mode 100644 index 0000000000..7064ad7d45 --- /dev/null +++ b/esphome/components/aranet/aranet.h @@ -0,0 +1,42 @@ +#pragma once + +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::aranet { + +class Aranet final : public Component, public ble_device_base::ESPBTDeviceListener { + public: + explicit Aranet(uint64_t address) : address_(address) {} + + bool parse_device(const ble_device_base::ESPBTDevice &device) override; + void dump_config() override; + + protected: + static constexpr uint8_t WARNED_INVALID_LENGTH = 1 << 0; + static constexpr uint8_t WARNED_INTEGRATION_DISABLED = 1 << 1; + static constexpr uint8_t WARNED_UNSUPPORTED_TYPE = 1 << 2; + + // Small state first so it fills the padding before the first pointer + uint16_t last_measurement_counter_{0x100}; // Out of range of the 8-bit counter until the first measurement + uint8_t warned_{0}; + + SUB_SENSOR(co2) + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(pressure) + SUB_SENSOR(battery_level) + SUB_SENSOR(measurement_interval) + SUB_SENSOR(measurement_age) + SUB_SENSOR(signal_strength) + SUB_SENSOR(radon) + SUB_SENSOR(radiation_rate) + SUB_SENSOR(radiation_total) + SUB_SENSOR(radiation_duration) + + protected: + uint64_t address_; +}; + +} // namespace esphome::aranet diff --git a/esphome/components/aranet/sensor.py b/esphome/components/aranet/sensor.py new file mode 100644 index 0000000000..f22d242f9c --- /dev/null +++ b/esphome/components/aranet/sensor.py @@ -0,0 +1,150 @@ +import esphome.codegen as cg +from esphome.components import ble_device_base, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_CO2, + CONF_HUMIDITY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_PRESSURE, + CONF_RADON, + CONF_SIGNAL_STRENGTH, + CONF_TEMPERATURE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_RADIOACTIVE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_BECQUEREL_PER_CUBIC_METER, + UNIT_CELSIUS, + UNIT_DECIBEL_MILLIWATT, + UNIT_HECTOPASCAL, + UNIT_MICROSILVERTS_PER_HOUR, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, + UNIT_SECOND, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["ble_device_base"] + +CONF_MEASUREMENT_AGE = "measurement_age" +CONF_MEASUREMENT_INTERVAL = "measurement_interval" +CONF_RADIATION_RATE = "radiation_rate" +CONF_RADIATION_TOTAL = "radiation_total" +CONF_RADIATION_DURATION = "radiation_duration" +UNIT_NANOSIEVERT = "nSv" + +aranet_ns = cg.esphome_ns.namespace("aranet") +Aranet = aranet_ns.class_("Aranet", ble_device_base.ESPBTDeviceListener, cg.Component) + +DIAGNOSTIC_DURATION_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Aranet), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_CO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE): sensor.sensor_schema( + unit_of_measurement=UNIT_HECTOPASCAL, + accuracy_decimals=1, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_MEASUREMENT_INTERVAL): DIAGNOSTIC_DURATION_SCHEMA, + cv.Optional(CONF_MEASUREMENT_AGE): DIAGNOSTIC_DURATION_SCHEMA, + cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RADON): sensor.sensor_schema( + unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_RADON, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RADIATION_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROSILVERTS_PER_HOUR, + accuracy_decimals=3, + icon=ICON_RADIOACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RADIATION_TOTAL): sensor.sensor_schema( + unit_of_measurement=UNIT_NANOSIEVERT, + accuracy_decimals=0, + icon=ICON_RADIOACTIVE, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_RADIATION_DURATION): sensor.sensor_schema( + unit_of_measurement=UNIT_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex) + await cg.register_component(var, config) + await ble_device_base.register_ble_device(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_MEASUREMENT_INTERVAL, var.set_measurement_interval_sensor) + await sensors(CONF_MEASUREMENT_AGE, var.set_measurement_age_sensor) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength_sensor) + await sensors(CONF_RADON, var.set_radon_sensor) + await sensors(CONF_RADIATION_RATE, var.set_radiation_rate_sensor) + await sensors(CONF_RADIATION_TOTAL, var.set_radiation_total_sensor) + await sensors(CONF_RADIATION_DURATION, var.set_radiation_duration_sensor) diff --git a/tests/components/aranet/common-ln.yaml b/tests/components/aranet/common-ln.yaml new file mode 100644 index 0000000000..1652ae5c6a --- /dev/null +++ b/tests/components/aranet/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: aranet + mac_address: 00:11:22:33:44:55 + co2: + name: Aranet4 Carbon Dioxide + temperature: + name: Aranet4 Temperature diff --git a/tests/components/aranet/common.h b/tests/components/aranet/common.h new file mode 100644 index 0000000000..41b5001a1f --- /dev/null +++ b/tests/components/aranet/common.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include "esphome/components/aranet/aranet.h" + +namespace esphome::aranet::testing { + +constexpr uint8_t MAC_ADDRESS[6] = {0x55, 0x44, 0x33, 0x22, 0x11, 0x00}; +constexpr uint64_t ADDRESS = 0x001122334455ULL; + +inline const std::vector ARANET4_PAYLOAD = { + 0x20, // Smart Home integrations enabled + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58, 0x02, // CO2: 600 ppm + 0xA4, 0x01, // Temperature: 21.0 C (420 / 20) + 0x94, 0x27, // Pressure: 1013.2 hPa (10132 / 10) + 45, // Humidity: 45% + 87, // Battery: 87% + 0x00, // Display status + 0x2C, 0x01, // Measurement interval: 300 s + 12, 0x00, // Measurement age: 12 s + 7, // Measurement counter +}; + +inline const std::vector ARANET2_PAYLOAD = {0x01, 0x21, 0x04, 0x04, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x99, 0x01, 0x00, 0x00, 0x0A, 0x02, + 0x00, 0x3B, 0x09, 0x78, 0x00, 0x52, 0x00, 0x64}; + +inline const std::vector RADIATION_PAYLOAD = {0x02, 0x21, 0x26, 0x04, 0x01, 0x00, 0xD0, 0x33, + 0x00, 0x00, 0x6C, 0x60, 0x06, 0x00, 0x82, 0x00, + 0x00, 0x63, 0x00, 0x2C, 0x01, 0x58, 0x00, 0x72}; + +inline const std::vector RADON_PAYLOAD = {0x03, 0x21, 0x04, 0x06, 0x01, 0x00, 0x00, 0x00, + 0x07, 0x00, 0xFE, 0x01, 0xC9, 0x27, 0xCE, 0x01, + 0x00, 0x64, 0x01, 0x58, 0x02, 0xF6, 0x01, 0x08}; + +inline ble_device_base::ESPBTDevice make_device(const std::vector &payload, int rssi = -61, + const uint8_t *mac_address = MAC_ADDRESS) { + // Manufacturer specific data AD structure for company id 0x0702 + std::vector advertisement{static_cast(payload.size() + 3), 0xFF, 0x02, 0x07}; + advertisement.insert(advertisement.end(), payload.begin(), payload.end()); + + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac_address, rssi, 0, advertisement.data(), advertisement.size()); + return device; +} + +struct Harness { + Harness() { + this->aranet.set_co2_sensor(&this->co2); + this->aranet.set_temperature_sensor(&this->temperature); + this->aranet.set_humidity_sensor(&this->humidity); + this->aranet.set_pressure_sensor(&this->pressure); + this->aranet.set_battery_level_sensor(&this->battery); + this->aranet.set_measurement_interval_sensor(&this->interval); + this->aranet.set_measurement_age_sensor(&this->age); + this->aranet.set_signal_strength_sensor(&this->signal_strength); + this->aranet.set_radon_sensor(&this->radon); + this->aranet.set_radiation_rate_sensor(&this->radiation_rate); + this->aranet.set_radiation_total_sensor(&this->radiation_total); + this->aranet.set_radiation_duration_sensor(&this->radiation_duration); + } + + Aranet aranet{ADDRESS}; + sensor::Sensor co2, temperature, humidity, pressure, battery, interval, age, signal_strength, radon, radiation_rate, + radiation_total, radiation_duration; +}; + +} // namespace esphome::aranet::testing diff --git a/tests/components/aranet/common.yaml b/tests/components/aranet/common.yaml new file mode 100644 index 0000000000..09338269ea --- /dev/null +++ b/tests/components/aranet/common.yaml @@ -0,0 +1,27 @@ +sensor: + - platform: aranet + mac_address: 00:11:22:33:44:55 + co2: + name: Aranet4 Carbon Dioxide + temperature: + name: Aranet4 Temperature + humidity: + name: Aranet4 Humidity + pressure: + name: Aranet4 Pressure + battery_level: + name: Aranet4 Battery Level + measurement_interval: + name: Aranet4 Measurement Interval + measurement_age: + name: Aranet4 Measurement Age + signal_strength: + name: Aranet4 Signal Strength + radon: + name: Aranet Radon Concentration + radiation_rate: + name: Aranet Radiation Dose Rate + radiation_total: + name: Aranet Radiation Total Dose + radiation_duration: + name: Aranet Radiation Dose Duration diff --git a/tests/components/aranet/sensor/test_aranet.cpp b/tests/components/aranet/sensor/test_aranet.cpp new file mode 100644 index 0000000000..deba1114e5 --- /dev/null +++ b/tests/components/aranet/sensor/test_aranet.cpp @@ -0,0 +1,144 @@ +#include + +#include +#include + +#include "../common.h" + +namespace esphome::aranet::testing { +namespace { + +TEST(Aranet, DecodesAranet4) { + Harness h; + auto device = make_device(ARANET4_PAYLOAD); + ASSERT_EQ(device.address_uint64(), ADDRESS); + + EXPECT_TRUE(h.aranet.parse_device(device)); + EXPECT_FLOAT_EQ(h.co2.state, 600.0f); + EXPECT_FLOAT_EQ(h.temperature.state, 21.0f); + EXPECT_FLOAT_EQ(h.humidity.state, 45.0f); + EXPECT_FLOAT_EQ(h.pressure.state, 1013.2f); + EXPECT_FLOAT_EQ(h.battery.state, 87.0f); + EXPECT_FLOAT_EQ(h.interval.state, 300.0f); + EXPECT_FLOAT_EQ(h.age.state, 12.0f); + EXPECT_FLOAT_EQ(h.signal_strength.state, -61.0f); + EXPECT_FALSE(h.radon.has_state()); + EXPECT_FALSE(h.radiation_rate.has_state()); + EXPECT_FALSE(h.radiation_total.has_state()); + EXPECT_FALSE(h.radiation_duration.has_state()); +} + +TEST(Aranet, UpdatesDiagnosticsForRepeatedMeasurement) { + Harness h; + int co2_updates = 0; + int age_updates = 0; + int signal_strength_updates = 0; + h.co2.add_on_state_callback([&co2_updates](float) { co2_updates++; }); + h.age.add_on_state_callback([&age_updates](float) { age_updates++; }); + h.signal_strength.add_on_state_callback([&signal_strength_updates](float) { signal_strength_updates++; }); + + EXPECT_TRUE(h.aranet.parse_device(make_device(ARANET4_PAYLOAD, -61))); + auto payload = ARANET4_PAYLOAD; + payload[19] = 18; + EXPECT_TRUE(h.aranet.parse_device(make_device(payload, -54))); + + EXPECT_EQ(co2_updates, 1); + EXPECT_EQ(age_updates, 2); + EXPECT_EQ(signal_strength_updates, 2); + EXPECT_FLOAT_EQ(h.age.state, 18.0f); + EXPECT_FLOAT_EQ(h.signal_strength.state, -54.0f); +} + +TEST(Aranet, RejectsDisabledIntegrationAndMalformedPayload) { + Harness h; + auto payload = ARANET4_PAYLOAD; + + payload[0] = 0; + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + + payload.pop_back(); + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.signal_strength.has_state()); +} + +TEST(Aranet, RejectsMismatchedAddressAndUnsupportedDeviceType) { + Harness h; + constexpr uint8_t other_mac_address[6] = {0x66, 0x44, 0x33, 0x22, 0x11, 0x00}; + EXPECT_FALSE(h.aranet.parse_device(make_device(ARANET4_PAYLOAD, -61, other_mac_address))); + + auto payload = ARANET4_PAYLOAD; + payload.insert(payload.begin(), 0x09); + payload.push_back(0x00); + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + + // Type 0 is Aranet4, which never sends the 24 byte layout; the second warning stays quiet + payload[0] = 0x00; + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + EXPECT_FALSE(h.signal_strength.has_state()); +} + +TEST(Aranet, DecodesAranet2) { + Harness h; + int temperature_updates = 0; + h.temperature.add_on_state_callback([&temperature_updates](float) { temperature_updates++; }); + + EXPECT_TRUE(h.aranet.parse_device(make_device(ARANET2_PAYLOAD))); + EXPECT_NEAR(h.temperature.state, 20.45f, 0.001f); + EXPECT_FLOAT_EQ(h.humidity.state, 52.2f); + EXPECT_FLOAT_EQ(h.battery.state, 59.0f); + EXPECT_FLOAT_EQ(h.interval.state, 120.0f); + EXPECT_FLOAT_EQ(h.age.state, 82.0f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.pressure.has_state()); + + auto repeated_payload = ARANET2_PAYLOAD; + repeated_payload[10] = 0x00; + repeated_payload[11] = 0x00; + EXPECT_TRUE(h.aranet.parse_device(make_device(repeated_payload))); + EXPECT_EQ(temperature_updates, 1); +} + +TEST(Aranet, DecodesAranetRadiation) { + Harness h; + EXPECT_TRUE(h.aranet.parse_device(make_device(RADIATION_PAYLOAD))); + EXPECT_FLOAT_EQ(h.radiation_rate.state, 0.13f); + EXPECT_FLOAT_EQ(h.radiation_total.state, 13264.0f); + EXPECT_FLOAT_EQ(h.radiation_duration.state, 417900.0f); + EXPECT_FLOAT_EQ(h.battery.state, 99.0f); + EXPECT_FLOAT_EQ(h.interval.state, 300.0f); + EXPECT_FLOAT_EQ(h.age.state, 88.0f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.temperature.has_state()); + EXPECT_FALSE(h.humidity.has_state()); + EXPECT_FALSE(h.pressure.has_state()); + EXPECT_FALSE(h.radon.has_state()); +} + +TEST(Aranet, DecodesAranetRadon) { + Harness h; + EXPECT_TRUE(h.aranet.parse_device(make_device(RADON_PAYLOAD))); + EXPECT_FLOAT_EQ(h.radon.state, 7.0f); + EXPECT_FLOAT_EQ(h.temperature.state, 25.5f); + EXPECT_FLOAT_EQ(h.humidity.state, 46.2f); + EXPECT_FLOAT_EQ(h.pressure.state, 1018.5f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.radiation_rate.has_state()); + EXPECT_FALSE(h.radiation_total.has_state()); + EXPECT_FALSE(h.radiation_duration.has_state()); +} + +TEST(Aranet, RadonOneSkipsZeroTemperatureAndHumidity) { + Harness h; + auto payload = RADON_PAYLOAD; + payload[10] = payload[11] = 0x00; + payload[14] = payload[15] = 0x00; + EXPECT_TRUE(h.aranet.parse_device(make_device(payload))); + EXPECT_FLOAT_EQ(h.radon.state, 7.0f); + EXPECT_FLOAT_EQ(h.pressure.state, 1018.5f); + EXPECT_FALSE(h.temperature.has_state()); + EXPECT_FALSE(h.humidity.has_state()); +} + +} // namespace +} // namespace esphome::aranet::testing diff --git a/tests/components/aranet/test.esp32-idf.yaml b/tests/components/aranet/test.esp32-idf.yaml new file mode 100644 index 0000000000..ca7354799f --- /dev/null +++ b/tests/components/aranet/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + aranet: !include common.yaml diff --git a/tests/components/aranet/test.ln882x-ard.yaml b/tests/components/aranet/test.ln882x-ard.yaml new file mode 100644 index 0000000000..9c195fc567 --- /dev/null +++ b/tests/components/aranet/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + aranet: !include common-ln.yaml diff --git a/tests/components/aranet/validate.bk72xx-ard.yaml b/tests/components/aranet/validate.bk72xx-ard.yaml new file mode 100644 index 0000000000..97a2fe61b2 --- /dev/null +++ b/tests/components/aranet/validate.bk72xx-ard.yaml @@ -0,0 +1,12 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: aranet + ble_hub_id: ble_hub + mac_address: 00:11:22:33:44:55 + co2: + name: BK Aranet4 Carbon Dioxide From 4cae63bbaa8deed355e7d262c58a6e105ca865c2 Mon Sep 17 00:00:00 2001 From: gledian Date: Tue, 13 Oct 2026 02:27:16 +0200 Subject: [PATCH 243/268] [ecocomfort2] Add the read-only sensors and clock sync (Part 2) (#15447) Co-authored-by: gledian Co-authored-by: Claude Opus 4.6 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/ecocomfort2/__init__.py | 7 +- .../components/ecocomfort2/binary_sensor.py | 33 +++ .../ecocomfort2/ecocomfort2_hub.cpp | 233 +++++++++++++++--- .../components/ecocomfort2/ecocomfort2_hub.h | 60 +++++ esphome/components/ecocomfort2/sensor.py | 62 +++++ esphome/components/ecocomfort2/text_sensor.py | 24 ++ tests/components/ecocomfort2/common.yaml | 35 +++ 7 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 esphome/components/ecocomfort2/binary_sensor.py create mode 100644 esphome/components/ecocomfort2/sensor.py create mode 100644 esphome/components/ecocomfort2/text_sensor.py diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py index 472ac8f7cf..8a0e165418 100644 --- a/esphome/components/ecocomfort2/__init__.py +++ b/esphome/components/ecocomfort2/__init__.py @@ -1,12 +1,12 @@ import esphome.codegen as cg -from esphome.components import ble_client, esp32_ble +from esphome.components import ble_client, esp32_ble, time from esphome.components.esp32_ble import ( CONF_AUTH_REQ_MODE, CONF_IO_CAPABILITY, CONF_MAX_KEY_SIZE, ) import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_TIME_ID from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -27,6 +27,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(Ecocomfort2Hub), + cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), } ) .extend(ble_client.BLE_CLIENT_SCHEMA) @@ -52,6 +53,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) + if (time_id := config.get(CONF_TIME_ID)) is not None: + cg.add(var.set_time(await cg.get_variable(time_id))) def _final_validate(config: ConfigType) -> ConfigType: diff --git a/esphome/components/ecocomfort2/binary_sensor.py b/esphome/components/ecocomfort2/binary_sensor.py new file mode 100644 index 0000000000..15e5b69d2d --- /dev/null +++ b/esphome/components/ecocomfort2/binary_sensor.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONF_BOOST = "boost" + +CONFIG_SCHEMA = cv.All( + ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BOOST): binary_sensor.binary_sensor_schema(), + } + ), + cv.has_at_least_one_key(CONF_CONNECTED, CONF_BOOST), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_CONNECTED, hub.set_connected_binary_sensor) + await sensors(CONF_BOOST, hub.set_boost_binary_sensor) diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp index a20fdd9112..00ce0f2080 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -3,22 +3,39 @@ #include "ecocomfort2_hub.h" #include "esphome/core/log.h" +#include + namespace esphome::ecocomfort2 { ESPHOME_LOG_TAG(TAG, "ecocomfort2"); -static const espbt::ESPBTUUID SERVICE_UUID = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d"); -static const espbt::ESPBTUUID C_OPER_UUID = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1"); +void Ecocomfort2Hub::setup() { +#ifdef USE_BINARY_SENSOR + if (this->connected_binary_sensor_ != nullptr) + this->connected_binary_sensor_->publish_initial_state(false); +#endif +#ifdef USE_TIME + // The clock is written after each time-source sync + if (this->time_ != nullptr) + this->time_->add_on_time_sync_callback([this]() { this->sync_clock_(); }); +#endif +} void Ecocomfort2Hub::update() { if (!this->is_ready()) { ESP_LOGD(TAG, "[%s] Not ready, skipping update", this->parent_->address_str()); return; } - auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), - this->char_handle_oper_, ESP_GATT_AUTH_REQ_NONE); + // The GATT client runs the requests one after the other + this->read_char_(this->char_handle_state_); + this->read_char_(this->char_handle_oper_); +} + +void Ecocomfort2Hub::read_char_(uint16_t handle) { + auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), handle, + ESP_GATT_AUTH_REQ_NONE); if (status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%s] C_OPER read request failed, status=%d", this->parent_->address_str(), status); + ESP_LOGW(TAG, "[%s] Read request for handle 0x%x failed, status=%d", this->parent_->address_str(), handle, status); } } @@ -28,6 +45,22 @@ void Ecocomfort2Hub::dump_config() { " Address: %s", this->parent_->address_str()); LOG_UPDATE_INTERVAL(this); +#ifdef USE_TIME + ESP_LOGCONFIG(TAG, " Clock sync: %s", YESNO(this->time_ != nullptr)); +#endif +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "VOC", this->voc_sensor_); + LOG_SENSOR(" ", "Direction", this->direction_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Connected", this->connected_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Boost", this->boost_binary_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Firmware", this->firmware_text_sensor_); +#endif } void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, @@ -39,17 +72,34 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if this->cancel_timeout(READBACK_TIMEOUT_ID); this->status_set_warning(); this->char_handle_oper_ = 0; + this->char_handle_state_ = 0; + this->char_handle_info_ = 0; + this->char_handle_clock_ = 0; + this->set_connected_(false); + this->publish_unknown_(); break; } case ESP_GATTC_SEARCH_CMPL_EVT: { - // Keep the handle, not the characteristic: ble_client frees its services once all nodes are established. - auto *chr = this->parent_->get_characteristic(SERVICE_UUID, C_OPER_UUID); - if (chr == nullptr) { - ESP_LOGW(TAG, "[%s] No C_OPER characteristic found, disconnecting", this->parent_->address_str()); + const auto service_uuid = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d"); + const auto oper_uuid = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1"); + const auto state_uuid = espbt::ESPBTUUID::from_raw("438d3433-7e5a-459a-a8e4-66343fad2bb0"); + const auto info_uuid = espbt::ESPBTUUID::from_raw("f5f56229-dd4f-480f-a829-9189269d8b37"); + const auto clock_uuid = espbt::ESPBTUUID::from_raw("82788997-49e4-4533-b949-7ed433678044"); + // Keep the handles, not the characteristics: ble_client frees its services once all nodes are established. + auto *oper = this->parent_->get_characteristic(service_uuid, oper_uuid); + auto *state = this->parent_->get_characteristic(service_uuid, state_uuid); + if (oper == nullptr || state == nullptr) { + ESP_LOGW(TAG, "[%s] C_OPER or C_STATE characteristic not found, disconnecting", this->parent_->address_str()); this->parent_->disconnect(); break; } - this->char_handle_oper_ = chr->handle; + this->char_handle_oper_ = oper->handle; + this->char_handle_state_ = state->handle; + // The version and clock characteristics only feed optional features, so a unit without them still works + auto *info = this->parent_->get_characteristic(service_uuid, info_uuid); + this->char_handle_info_ = info != nullptr ? info->handle : 0; + auto *clock = this->parent_->get_characteristic(service_uuid, clock_uuid); + this->char_handle_clock_ = clock != nullptr ? clock->handle : 0; this->node_state = espbt::ClientState::ESTABLISHED; this->status_set_warning(); @@ -71,31 +121,22 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if break; } case ESP_GATTC_READ_CHAR_EVT: { - if (param->read.conn_id != this->parent_->get_conn_id() || param->read.handle != this->char_handle_oper_) { + if (param->read.conn_id != this->parent_->get_conn_id()) { break; } - if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) { - // ble_client reconnects and pairs again - ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str()); - this->parent_->disconnect(); + const uint16_t handle = param->read.handle; + if (handle == this->char_handle_oper_) { + if (this->read_ok_(param)) + this->on_oper_(param->read.value, param->read.value_len); + } else if (handle == this->char_handle_state_) { + if (this->read_ok_(param)) + this->on_state_(param->read.value, param->read.value_len); + } else if (handle == this->char_handle_info_) { + if (this->read_ok_(param)) + this->on_info_(param->read.value, param->read.value_len); + } else { break; } - if (param->read.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%s] C_OPER read failed, status=%d", this->parent_->address_str(), param->read.status); - break; - } - if (param->read.value_len < 2) { - ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), param->read.value_len); - break; - } - this->mode_ = param->read.value[0]; - this->speed_ = param->read.value[1]; - ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); -#ifdef ECOCOMFORT2_CHILD_COUNT - for (auto *child : this->children_) { - child->on_status(); - } -#endif break; } default: @@ -103,6 +144,21 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if } } +bool Ecocomfort2Hub::read_ok_(const esp_ble_gattc_cb_param_t *param) { + if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) { + // ble_client reconnects and pairs again + ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + return false; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] Read of handle 0x%x failed, status=%d", this->parent_->address_str(), param->read.handle, + param->read.status); + return false; + } + return true; +} + void Ecocomfort2Hub::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { if (event != ESP_GAP_BLE_AUTH_CMPL_EVT || !this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr) || this->node_state != espbt::ClientState::ESTABLISHED) { @@ -122,9 +178,122 @@ void Ecocomfort2Hub::on_paired_() { this->cancel_timeout(AUTH_TIMEOUT_ID); this->status_clear_warning(); ESP_LOGI(TAG, "[%s] Encryption confirmed, device ready", this->parent_->address_str()); + this->set_connected_(true); +#ifdef USE_TIME + if (this->time_ != nullptr) + this->sync_clock_(); +#endif +#ifdef USE_TEXT_SENSOR + if (this->firmware_text_sensor_ != nullptr && this->char_handle_info_ != 0) + this->read_char_(this->char_handle_info_); +#endif this->update(); } +void Ecocomfort2Hub::set_connected_(bool connected) { +#ifdef USE_BINARY_SENSOR + if (this->connected_binary_sensor_ != nullptr) + this->connected_binary_sensor_->publish_state(connected); +#endif +} + +// Readings from a unit that is out of reach would otherwise look current for hours +void Ecocomfort2Hub::publish_unknown_() { +#ifdef USE_SENSOR + for (auto *sensor : {this->temperature_sensor_, this->humidity_sensor_, this->voc_sensor_, this->direction_sensor_}) { + if (sensor != nullptr) + sensor->publish_state(NAN); + } +#endif +} + +// C_OPER: mode byte, speed/flags byte +void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { + if (len < 2) { + ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), len); + return; + } + this->mode_ = data[0]; + this->speed_ = data[1]; + ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->publish_state((this->speed_ & FLAG_BOOST) != 0); +#endif +#ifdef ECOCOMFORT2_CHILD_COUNT + for (auto *child : this->children_) { + child->on_status(); + } +#endif +} + +// C_STATE: bytes 3-4 temperature and 5-6 humidity in hundredths, 7-8 VOC in ppb, byte 2 the air flow direction +void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { + if (len < 9) { + ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len); + return; + } + const float temperature = static_cast(encode_uint16(data[3], data[4])) / 100.0f; + const float humidity = encode_uint16(data[5], data[6]) / 100.0f; + const uint16_t voc = encode_uint16(data[7], data[8]); + ESP_LOGD(TAG, "[%s] C_STATE: %.2f °C, %.2f %%, %u ppb, direction=%u", this->parent_->address_str(), temperature, + humidity, voc, data[2]); +#ifdef USE_SENSOR + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(humidity); + if (this->voc_sensor_ != nullptr) + this->voc_sensor_->publish_state(voc); + if (this->direction_sensor_ != nullptr) + this->direction_sensor_->publish_state(data[2]); +#endif +} + +// C_INFO: firmware version packed as 4-bit major, 6-bit minor and 6-bit patch +void Ecocomfort2Hub::on_info_(const uint8_t *data, uint16_t len) { + if (len < 2) { + ESP_LOGW(TAG, "[%s] C_INFO too short: %u bytes", this->parent_->address_str(), len); + return; + } + const uint16_t field = encode_uint16(data[0] & 0x0F, data[1]); + char version[12]; + snprintf(version, sizeof(version), "%u.%u.%u", data[0] >> 4, field >> 6, field & 0x3F); + ESP_LOGD(TAG, "[%s] Firmware %s", this->parent_->address_str(), version); +#ifdef USE_TEXT_SENSOR + if (this->firmware_text_sensor_ != nullptr) + this->firmware_text_sensor_->publish_state(version); +#endif +} + +#ifdef USE_TIME +// C_CLOCK: hour, minute, second, weekday with Monday as 0, day, month, year as two bytes +void Ecocomfort2Hub::sync_clock_() { + if (!this->is_ready() || this->char_handle_clock_ == 0) + return; + const ESPTime now = this->time_->now(); + if (!now.is_valid()) + return; + // ESPTime counts Sunday=1 .. Saturday=7, the unit Monday=0 .. Sunday=6 + const uint8_t weekday = (now.day_of_week + 5) % 7; + uint8_t data[8]{now.hour, + now.minute, + now.second, + weekday, + now.day_of_month, + now.month, + static_cast(now.year >> 8), + static_cast(now.year)}; + ESP_LOGD(TAG, "[%s] Syncing clock to %02u:%02u:%02u", this->parent_->address_str(), now.hour, now.minute, now.second); + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_clock_, + sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_CLOCK write failed, status=%d", this->parent_->address_str(), status); + } +} +#endif + void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) { if (!this->is_ready()) { ESP_LOGW(TAG, "[%s] Not ready, cannot write C_OPER", this->parent_->address_str()); @@ -139,7 +308,7 @@ void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) { ESP_LOGW(TAG, "[%s] C_OPER write failed, status=%d", this->parent_->address_str(), status); return; } - this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->update(); }); + this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->read_char_(this->char_handle_oper_); }); } #ifdef ECOCOMFORT2_CHILD_COUNT diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h index 5a609d9283..ed046fe049 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.h +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -8,6 +8,19 @@ #include "esphome/core/helpers.h" #include "ecocomfort2_child.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_TIME +#include "esphome/components/time/real_time_clock.h" +#endif + namespace esphome::ecocomfort2 { namespace espbt = esphome::esp32_ble_tracker; @@ -33,6 +46,7 @@ static constexpr uint8_t FLAG_NIGHT = 0x80; /** Hub component for Ecocomfort 2.0 VMC via BLE. */ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent { public: + void setup() override; void update() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BLUETOOTH; } @@ -44,6 +58,23 @@ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public Polling #ifdef ECOCOMFORT2_CHILD_COUNT void register_child(Ecocomfort2Client *obj); #endif +#ifdef USE_SENSOR + void set_temperature_sensor(sensor::Sensor *s) { this->temperature_sensor_ = s; } + void set_humidity_sensor(sensor::Sensor *s) { this->humidity_sensor_ = s; } + void set_voc_sensor(sensor::Sensor *s) { this->voc_sensor_ = s; } + void set_direction_sensor(sensor::Sensor *s) { this->direction_sensor_ = s; } +#endif +#ifdef USE_BINARY_SENSOR + void set_connected_binary_sensor(binary_sensor::BinarySensor *s) { this->connected_binary_sensor_ = s; } + void set_boost_binary_sensor(binary_sensor::BinarySensor *s) { this->boost_binary_sensor_ = s; } +#endif +#ifdef USE_TEXT_SENSOR + void set_firmware_text_sensor(text_sensor::TextSensor *s) { this->firmware_text_sensor_ = s; } +#endif +#ifdef USE_TIME + /// Keeps the unit's clock in step with this time source while connected. + void set_time(time::RealTimeClock *time) { this->time_ = time; } +#endif /// True once the link is established and paired. bool is_ready() const { return this->node_state == espbt::ClientState::ESTABLISHED && this->parent_->is_paired(); } @@ -62,11 +93,40 @@ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public Polling static constexpr uint32_t READBACK_DELAY_MS = 300; void on_paired_(); + void set_connected_(bool connected); + void publish_unknown_(); + void read_char_(uint16_t handle); + bool read_ok_(const esp_ble_gattc_cb_param_t *param); + void on_oper_(const uint8_t *data, uint16_t len); + void on_state_(const uint8_t *data, uint16_t len); + void on_info_(const uint8_t *data, uint16_t len); +#ifdef USE_TIME + void sync_clock_(); +#endif #ifdef ECOCOMFORT2_CHILD_COUNT StaticVector children_; +#endif +#ifdef USE_SENSOR + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + sensor::Sensor *voc_sensor_{nullptr}; + sensor::Sensor *direction_sensor_{nullptr}; +#endif +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *connected_binary_sensor_{nullptr}; + binary_sensor::BinarySensor *boost_binary_sensor_{nullptr}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *firmware_text_sensor_{nullptr}; +#endif +#ifdef USE_TIME + time::RealTimeClock *time_{nullptr}; #endif uint16_t char_handle_oper_{0}; + uint16_t char_handle_state_{0}; + uint16_t char_handle_info_{0}; + uint16_t char_handle_clock_{0}; uint8_t mode_{OPER_OFF}; uint8_t speed_{SPEED_OFF}; }; diff --git a/esphome/components/ecocomfort2/sensor.py b/esphome/components/ecocomfort2/sensor.py new file mode 100644 index 0000000000..878cc05f87 --- /dev/null +++ b/esphome/components/ecocomfort2/sensor.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DIRECTION, + CONF_HUMIDITY, + CONF_TEMPERATURE, + CONF_VOC, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PARTS_PER_BILLION, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONFIG_SCHEMA = cv.All( + ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_VOC): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DIRECTION): sensor.sensor_schema( + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ), + cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_HUMIDITY, CONF_VOC, CONF_DIRECTION), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_HUMIDITY, hub.set_humidity_sensor) + await sensors(CONF_VOC, hub.set_voc_sensor) + await sensors(CONF_DIRECTION, hub.set_direction_sensor) diff --git a/esphome/components/ecocomfort2/text_sensor.py b/esphome/components/ecocomfort2/text_sensor.py new file mode 100644 index 0000000000..4f2ac36539 --- /dev/null +++ b/esphome/components/ecocomfort2/text_sensor.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONFIG_SCHEMA = ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Required(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = text_sensor.sub_text_sensors(config) + await sensors(CONF_VERSION, hub.set_firmware_text_sensor) diff --git a/tests/components/ecocomfort2/common.yaml b/tests/components/ecocomfort2/common.yaml index b4951e804f..84fd33a983 100644 --- a/tests/components/ecocomfort2/common.yaml +++ b/tests/components/ecocomfort2/common.yaml @@ -3,6 +3,14 @@ esp32_ble: auth_req_mode: sc_bond max_key_size: 16 +wifi: + ssid: MySSID + password: password1 + +time: + - platform: sntp + id: sntp_time + ble_client: - mac_address: 01:02:03:04:05:06 id: ecocomfort2_ble_1 @@ -12,6 +20,7 @@ ble_client: ecocomfort2: - id: ecocomfort2_hub_1 ble_client_id: ecocomfort2_ble_1 + time_id: sntp_time - id: ecocomfort2_hub_2 ble_client_id: ecocomfort2_ble_2 @@ -22,3 +31,29 @@ fan: - platform: ecocomfort2 name: Ecocomfort2 Fan 2 ecocomfort2_id: ecocomfort2_hub_2 + +sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_1 + temperature: + name: Ecocomfort2 Temperature + humidity: + name: Ecocomfort2 Humidity + voc: + name: Ecocomfort2 VOC + direction: + name: Ecocomfort2 Direction + +binary_sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_1 + connected: + name: Ecocomfort2 Connected + boost: + name: Ecocomfort2 Boost + +text_sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_2 + version: + name: Ecocomfort2 Firmware From dc9a7cffbdbb7490c791ef65fe9b362230af64f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:28:38 +0200 Subject: [PATCH 244/268] [ota] Refuse a second OTA while one is already running (#20588) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/ota/ota_backend.cpp | 58 ++++++++++ esphome/components/ota/ota_backend.h | 24 ++++ .../ota/ota_backend_arduino_libretiny.cpp | 9 +- .../ota/ota_backend_arduino_libretiny.h | 1 + .../ota/ota_backend_arduino_rp2.cpp | 5 + .../components/ota/ota_backend_arduino_rp2.h | 1 + .../components/ota/ota_backend_esp8266.cpp | 5 + esphome/components/ota/ota_backend_esp8266.h | 1 + .../components/ota/ota_backend_esp_idf.cpp | 10 +- esphome/components/ota/ota_backend_esp_idf.h | 6 + esphome/components/ota/ota_backend_host.cpp | 5 + esphome/components/ota/ota_backend_host.h | 1 + .../web_server/ota/ota_web_server.cpp | 104 +++++++++++------- .../web_server_idf/web_server_idf.cpp | 4 + .../web_server_idf/web_server_idf.h | 9 ++ esphome/espota2.py | 5 + tests/components/ota/test_update_lock.cpp | 85 ++++++++++++++ tests/unit_tests/test_espota2.py | 1 + 18 files changed, 292 insertions(+), 42 deletions(-) create mode 100644 tests/components/ota/test_update_lock.cpp diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 0447b968a3..a83195d42d 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -1,5 +1,9 @@ #include "ota_backend.h" +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#include +#endif + namespace esphome::ota { bool version_is_older(const char *candidate, const char *reference) { @@ -30,6 +34,60 @@ bool version_is_older(const char *candidate, const char *reference) { } } +// Non-blocking; may be released by another task than the one that took it. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// uint32_t: narrower atomics compile to a masked CAS on Xtensa. +static std::atomic update_locked{0}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static bool update_lock_test_and_set() { return update_locked.exchange(1) == 0; } +static void update_lock_clear() { update_locked.store(0); } +#else +static bool update_locked = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS +// BK72xx has no atomic exchange. +static Mutex &update_lock_mutex() { + static Mutex mutex; + return mutex; +} +#endif + +static bool update_lock_test_and_set() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#else + // lwIP callbacks may run from an interrupt (RP2040). + InterruptLock guard; +#endif + if (update_locked) + return false; + update_locked = true; + return true; +} + +static void update_lock_clear() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#endif + update_locked = false; +} +#endif + +bool UpdateLock::try_acquire() { + if (!this->held_) + this->held_ = update_lock_test_and_set(); + return this->held_; +} + +void UpdateLock::release() { + if (!this->held_) + return; + update_lock_clear(); + this->held_ = false; +} + +void UpdateLock::force_release() { update_lock_clear(); } + #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index a505d58efe..840d1ac11c 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -51,6 +51,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94, + OTA_RESPONSE_ERROR_UPDATE_BUSY = 0x95, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -95,6 +96,29 @@ enum OTAType : uint8_t { OTA_TYPE_UPDATE_BOOTLOADER = 0x02, }; +/// Serializes flash updates. Taken in begin(), released when the backend is destroyed, or kept until +/// reboot after a successful end(). Also for components writing the update partition outside OTA. +class UpdateLock { + public: + UpdateLock() = default; + ~UpdateLock() { this->release(); } + UpdateLock(const UpdateLock &) = delete; + UpdateLock &operator=(const UpdateLock &) = delete; + + /// Returns false if held elsewhere. + bool try_acquire(); + void release(); + bool is_held() const { return this->held_; } + /// Keep the lock set until reboot; is_held() becomes false. + void hold_until_reboot() { this->held_ = false; } + + protected: + /// Tests only. + static void force_release(); + + bool held_{false}; +}; + // The OTA backend method surface. Exactly one backend exists per build, // selected in ota_backend_factory.h where this concept is asserted on // make_ota_backend()'s return type. Semantics beyond the signatures: diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 02a5c5750f..33af42b6a7 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -14,6 +14,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -57,6 +59,7 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -66,7 +69,11 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoLibreTinyOTABackend::abort() { Update.abort(); } +void ArduinoLibreTinyOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; + Update.abort(); +} } // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index c322ed21f2..fc277bb5e4 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -16,6 +16,7 @@ class ArduinoLibreTinyOTABackend final { static constexpr bool supports_compression() { return false; } private: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index 74f44c0b3b..d4d94be236 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -16,6 +16,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -63,6 +65,7 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -73,6 +76,8 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { } void ArduinoRP2OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; Update.end(); rp2::preferences_prevent_write(false); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.h b/esphome/components/ota/ota_backend_arduino_rp2.h index 15142869ac..93b9dcb75c 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -21,6 +21,7 @@ class ArduinoRP2OTABackend final { static constexpr bool supports_compression() { return USE_ARDUINO_VERSION_CODE >= VERSION_CODE(4, 0, 3); } private: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 5be55a5200..3d6148e64f 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -51,6 +51,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ESP8266OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -303,10 +305,13 @@ OTAResponseTypes ESP8266OTABackend::end() { this->buffer_.reset(); esp8266::preferences_prevent_write(false); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void ESP8266OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; this->buffer_.reset(); this->buffer_len_ = 0; this->image_size_ = 0; diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 1f1ec37eee..bd4c9a1bce 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -23,6 +23,7 @@ class ESP8266OTABackend final { static constexpr bool supports_compression() { return true; } protected: + UpdateLock lock_; /// Erase flash sector if current address is at sector boundary bool erase_sector_if_needed_(); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2316aad5cf..fd6fd7f6f1 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -20,6 +20,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; #ifdef USE_OTA_PARTITIONS this->ota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { @@ -196,7 +198,7 @@ OTAResponseTypes IDFOTABackend::end() { // signature, and only re-points boot at an already-installed app -- so it is // intentionally not run through the signature verifier below. if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - return this->update_partition_table(); + return this->hold_lock_if_ok_(this->update_partition_table()); } if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; @@ -209,7 +211,7 @@ OTAResponseTypes IDFOTABackend::end() { } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_BOOTLOADER) { - return this->finalize_bootloader_update_(err); + return this->hold_lock_if_ok_(this->finalize_bootloader_update_(err)); } #endif if (err == ESP_OK) { @@ -242,7 +244,7 @@ OTAResponseTypes IDFOTABackend::end() { #endif err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { - return OTA_RESPONSE_OK; + return this->hold_lock_if_ok_(OTA_RESPONSE_OK); } } if (err == ESP_ERR_OTA_VALIDATE_FAILED) { @@ -260,6 +262,8 @@ OTAResponseTypes IDFOTABackend::end() { } void IDFOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; #ifdef USE_OTA_PARTITIONS if (this->partition_table_part_ != nullptr) { esp_partition_deregister_external(this->partition_table_part_); diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 4f4093a594..d166bb361f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -36,6 +36,12 @@ class IDFOTABackend final { static constexpr bool supports_compression() { return false; } protected: + UpdateLock lock_; + OTAResponseTypes hold_lock_if_ok_(OTAResponseTypes result) { + if (result == OTA_RESPONSE_OK) + this->lock_.hold_until_reboot(); + return result; + } #ifdef USE_OTA_PARTITIONS // copy_dest_part non-null means the running app must be copied INTO this slot of the current // table before the new partition table is committed. The destination is in the current table diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 456fbb5863..923b0afe18 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -200,6 +200,8 @@ bool validate_executable_(const char *staging_path, const std::string &exe_path) std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; // 0 = unknown size (web_server multipart); cap at MAX_OTA_SIZE. @@ -318,10 +320,13 @@ OTAResponseTypes HostOTABackend::end() { host::arm_reexec(this->final_path_); this->staging_path_.clear(); ESP_LOGI(TAG, "OTA staged at %s; will re-exec on reboot", this->final_path_.c_str()); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void HostOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; if (this->fd_ >= 0) { ::close(this->fd_); this->fd_ = -1; diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index e53868f102..6c86fe10ba 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -22,6 +22,7 @@ class HostOTABackend final { static constexpr bool supports_compression() { return false; } protected: + UpdateLock lock_; md5::MD5Digest md5_{}; std::string staging_path_; std::string final_path_; diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 3cf1d9802e..8a668ae0fc 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -9,12 +9,6 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) -#include -#endif -#endif // USE_ARDUINO - #if USE_ESP32 using PlatformString = std::string; #elif USE_ARDUINO @@ -64,6 +58,10 @@ class OTARequestHandler : public AsyncWebHandler { void report_ota_progress_(AsyncWebServerRequest *request); void schedule_ota_reboot_(); void ota_init_(const char *filename); + /// Abort and drop the backend, releasing the lock. + void abort_session_(); + /// Reply "busy" without touching the running upload. + void refuse_(AsyncWebServerRequest *request); uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; @@ -72,6 +70,9 @@ class OTARequestHandler : public AsyncWebHandler { private: ota::OTABackendPtr ota_backend_{nullptr}; + /// Request that owns ota_backend_. + const AsyncWebServerRequest *ota_request_{nullptr}; + const AsyncWebServerRequest *refused_request_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { @@ -118,38 +119,13 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // fires a separate start-marker call with data==nullptr/len==0 before the // first real chunk; gate on len>0 so we only trigger once per upload.) if (index == 0 && len > 0) { - // If a previous upload was interrupted (e.g. client closed the tab, TCP - // reset) the backend from that session may still be open. Tear it down - // so flash state doesn't get concatenated with the new image (which can - // produce a technically-valid-sized but corrupted firmware that bricks - // the device once it reboots). + // A stalled upload times out and onDisconnect() frees it, so refusing can't block for long. if (this->ota_backend_) { - ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); - this->ota_backend_->abort(); -#ifdef USE_OTA_STATE_LISTENER - // Notify listeners that the previous session was aborted before the new one starts. - this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); -#endif - this->ota_backend_.reset(); + ESP_LOGW(TAG, "OTA upload refused: another upload is in progress"); + this->refuse_(request); + return; } - // Initialize OTA on first call - this->ota_init_(filename.c_str()); - -#ifdef USE_OTA_STATE_LISTENER - // Notify OTA started - use notify_state_deferred_ since we're in web server task - this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); -#endif - - // Platform-specific pre-initialization -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) - if (Update.isRunning()) { - Update.abort(); - } -#endif -#endif // USE_ARDUINO - this->ota_backend_ = ota::make_ota_backend(); if (!this->ota_backend_) { ESP_LOGE(TAG, "Failed to create OTA backend"); @@ -164,6 +140,22 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // is unknown (contentLength includes multipart overhead) // Pass 0 to indicate unknown size error_code = this->ota_backend_->begin(0); + if (error_code == ota::OTA_RESPONSE_ERROR_UPDATE_BUSY) { + // Nothing started: no state notifications. + ESP_LOGW(TAG, "OTA upload refused: another update is in progress"); + this->ota_backend_.reset(); + this->refuse_(request); + return; + } + + // Initialize OTA on first call + this->ota_init_(filename.c_str()); + +#ifdef USE_OTA_STATE_LISTENER + // Notify OTA started - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); +#endif + if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); this->ota_backend_.reset(); @@ -172,9 +164,18 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf #endif return; } + + // An interrupted upload gets no final chunk. + this->ota_request_ = request; + request->onDisconnect([this, request]() { + if (this->ota_request_ == request && this->ota_backend_) { + ESP_LOGW(TAG, "OTA upload interrupted; aborting"); + this->abort_session_(); + } + }); } - if (!this->ota_backend_) { + if (!this->ota_backend_ || request != this->ota_request_) { return; } @@ -185,6 +186,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); this->ota_backend_.reset(); + this->ota_request_ = nullptr; #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -212,28 +214,54 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", error_code); + // Reset the platform updater before the lock is released. + this->ota_backend_->abort(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } this->ota_backend_.reset(); + this->ota_request_ = nullptr; } } +void OTARequestHandler::abort_session_() { + this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif + this->ota_backend_.reset(); + this->ota_request_ = nullptr; +} + +void OTARequestHandler::refuse_(AsyncWebServerRequest *request) { + this->refused_request_ = request; + request->onDisconnect([this, request]() { + if (this->refused_request_ == request) + this->refused_request_ = nullptr; + }); +} + void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { AsyncWebServerResponse *response; // Use the ota_success_ flag to determine the actual result + const bool refused = request == this->refused_request_; + if (refused) + this->refused_request_ = nullptr; #ifdef USE_ESP8266 static const char UPDATE_SUCCESS[] PROGMEM = "Update Successful!"; static const char UPDATE_FAILED[] PROGMEM = "Update Failed!"; + static const char UPDATE_BUSY[] PROGMEM = "Update Failed! Another update is in progress, retry later."; static const char TEXT_PLAIN[] PROGMEM = "text/plain"; static const char CONNECTION_STR[] PROGMEM = "Connection"; static const char CLOSE_STR[] PROGMEM = "close"; - const char *msg = this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED; + const char *msg = refused ? UPDATE_BUSY : (this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED); response = request->beginResponse_P(200, TEXT_PLAIN, msg); response->addHeader(CONNECTION_STR, CLOSE_STR); #else - const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!"; + const char *msg = refused ? "Update Failed! Another update is in progress, retry later." + : this->ota_success_ ? "Update Successful!" + : "Update Failed!"; response = request->beginResponse(200, "text/plain", msg); response->addHeader("Connection", "close"); #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 2153e232a0..d9a7d65e8c 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -311,6 +311,10 @@ esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_t } AsyncWebServerRequest::~AsyncWebServerRequest() { +#ifdef USE_WEBSERVER_OTA + if (this->on_disconnect_) + this->on_disconnect_(); +#endif delete this->rsp_; for (auto *param : this->params_) { delete param; // NOLINT(cppcoreguidelines-owning-memory) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index f9094b05c5..4d4490d907 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -183,6 +183,12 @@ class AsyncWebServerRequest { std::string arg(const char *name); std::string arg(const std::string &name) { return this->arg(name.c_str()); } +#ifdef USE_WEBSERVER_OTA + /// Called when the request ends, including early returns. + // NOLINTNEXTLINE(readability-identifier-naming) + void onDisconnect(std::function fn) { this->on_disconnect_ = std::move(fn); } +#endif + operator httpd_req_t *() const { return this->req_; } optional get_header(const char *name) const; // NOLINTNEXTLINE(readability-identifier-naming) @@ -198,6 +204,9 @@ class AsyncWebServerRequest { optional find_query_value_(const char *name) const; std::vector params_; std::string post_query_; +#ifdef USE_WEBSERVER_OTA + std::function on_disconnect_; +#endif AsyncWebServerRequest(httpd_req_t *req) : req_(req) {} AsyncWebServerRequest(httpd_req_t *req, std::string post_query) : req_(req), post_query_(std::move(post_query)) {} void init_response_(AsyncWebServerResponse *rsp, int code, const char *content_type); diff --git a/esphome/espota2.py b/esphome/espota2.py index f775afe7f6..d8a2ff9000 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -54,6 +54,7 @@ RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91 RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92 RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93 RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94 +RESPONSE_ERROR_UPDATE_BUSY = 0x95 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -203,6 +204,10 @@ _ERROR_MESSAGES: dict[int, str] = { "of the YAML this upload uses, or update your esphome installation if it " "predates OTA encryption." ), + RESPONSE_ERROR_UPDATE_BUSY: ( + "The device is busy with another update that writes to its flash. " + "Wait for it to finish, then retry." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } diff --git a/tests/components/ota/test_update_lock.cpp b/tests/components/ota/test_update_lock.cpp new file mode 100644 index 0000000000..28314cb1c2 --- /dev/null +++ b/tests/components/ota/test_update_lock.cpp @@ -0,0 +1,85 @@ +#include + +#include "esphome/components/ota/ota_backend_factory.h" + +namespace esphome::ota::testing { + +TEST(UpdateLock, SecondHolderIsRefused) { + UpdateLock first; + UpdateLock second; + EXPECT_TRUE(first.try_acquire()); + EXPECT_FALSE(second.try_acquire()); + first.release(); + EXPECT_TRUE(second.try_acquire()); +} + +TEST(UpdateLock, AcquireIsIdempotentForTheHolder) { + UpdateLock lock; + EXPECT_TRUE(lock.try_acquire()); + EXPECT_TRUE(lock.try_acquire()); + lock.release(); + UpdateLock other; + EXPECT_TRUE(other.try_acquire()); +} + +TEST(UpdateLock, ReleaseByNonHolderKeepsLock) { + UpdateLock holder; + UpdateLock other; + EXPECT_TRUE(holder.try_acquire()); + other.release(); + EXPECT_FALSE(other.try_acquire()); +} + +TEST(UpdateLock, DestructorReleases) { + { + UpdateLock scoped; + EXPECT_TRUE(scoped.try_acquire()); + } + UpdateLock next; + EXPECT_TRUE(next.try_acquire()); +} + +TEST(UpdateLock, IsHeldTracksOwnership) { + UpdateLock holder; + UpdateLock other; + EXPECT_FALSE(holder.is_held()); + EXPECT_TRUE(holder.try_acquire()); + EXPECT_TRUE(holder.is_held()); + EXPECT_FALSE(other.try_acquire()); + EXPECT_FALSE(other.is_held()); +} + +class TestUpdateLock : public UpdateLock { + public: + using UpdateLock::force_release; +}; + +TEST(UpdateLock, HoldUntilRebootOutlivesHolder) { + { + UpdateLock committed; + EXPECT_TRUE(committed.try_acquire()); + committed.hold_until_reboot(); + EXPECT_FALSE(committed.is_held()); + } + UpdateLock next; + EXPECT_FALSE(next.try_acquire()); + TestUpdateLock::force_release(); + EXPECT_TRUE(next.try_acquire()); +} + +TEST(UpdateLock, SecondBackendBeginIsBusy) { + auto first = make_ota_backend(); + auto second = make_ota_backend(); + ASSERT_EQ(first->begin(1024), OTA_RESPONSE_OK); + EXPECT_EQ(second->begin(1024), OTA_RESPONSE_ERROR_UPDATE_BUSY); + // Native OTA calls abort() after a refused begin(). + second->abort(); + uint8_t data[16] = {}; + EXPECT_EQ(first->write(data, sizeof(data)), OTA_RESPONSE_OK); + first->abort(); + first.reset(); + EXPECT_EQ(second->begin(1024), OTA_RESPONSE_OK); + second->abort(); +} + +} // namespace esphome::ota::testing diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 6fa6c616b6..f1e04da8c8 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -263,6 +263,7 @@ def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) - espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE, "An error occurred while updating the bootloader", ), + (espota2.RESPONSE_ERROR_UPDATE_BUSY, "The device is busy with another update"), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) From a3e373b063ff9a60e4b3f72d72fa1b7633c04615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:31:36 -1000 Subject: [PATCH 245/268] [noise] Add generate_encryption_key and use it in the wizard and dashboard_import (#20600) --- esphome/components/dashboard_import/__init__.py | 6 ++---- esphome/components/noise/__init__.py | 6 ++++++ esphome/wizard.py | 6 ++---- tests/component_tests/noise/test_encryption_key.py | 14 +++++++++++++- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index aca0901d2b..576887f6dc 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -1,13 +1,12 @@ -import base64 from pathlib import Path import re -import secrets from typing import Any from ruamel.yaml import YAML from esphome import git import esphome.codegen as cg +from esphome.components.noise import generate_encryption_key from esphome.components.packages import validate_source_shorthand import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI @@ -175,8 +174,7 @@ def import_config( "esphome": esphome_core, } if encryption: - noise_psk = secrets.token_bytes(32) - key = base64.b64encode(noise_psk).decode() + key = generate_encryption_key() config["api"] = {"encryption": {"key": key}} output = dump(config) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 1769c11018..781079c190 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -1,5 +1,6 @@ import base64 import binascii +import secrets from typing import Any import esphome.codegen as cg @@ -22,6 +23,11 @@ noise_ns = cg.esphome_ns.namespace("noise") CONFIG_SCHEMA = cv.Schema({}) +def generate_encryption_key() -> str: + """A fresh random key in the base64 form the yaml takes.""" + return base64.b64encode(secrets.token_bytes(32)).decode() + + def validate_encryption_key(value: Any) -> str: value = cv.string_strict(value) try: diff --git a/esphome/wizard.py b/esphome/wizard.py index 897d5f60a1..10ea0d0b4c 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,4 +1,3 @@ -import base64 from pathlib import Path import secrets import string @@ -7,6 +6,7 @@ import unicodedata import voluptuous as vol +from esphome.components.noise import generate_encryption_key import esphome.config_validation as cv from esphome.const import ALLOWED_NAME_CHARS, ENV_QUICKWIZARD from esphome.core import CORE @@ -516,9 +516,7 @@ def wizard(path: Path) -> int: safe_print() sleep(0.5) - # Generate encryption key (32 bytes, base64 encoded) for secure API communication - noise_psk = secrets.token_bytes(32) - api_encryption_key = base64.b64encode(noise_psk).decode() + api_encryption_key = generate_encryption_key() safe_print( "For secure API communication, I've generated a random encryption key." diff --git a/tests/component_tests/noise/test_encryption_key.py b/tests/component_tests/noise/test_encryption_key.py index 2b79bd5464..3a895e25de 100644 --- a/tests/component_tests/noise/test_encryption_key.py +++ b/tests/component_tests/noise/test_encryption_key.py @@ -5,7 +5,11 @@ from __future__ import annotations import pytest from esphome import config_validation as cv -from esphome.components.noise import decode_encryption_key, validate_encryption_key +from esphome.components.noise import ( + decode_encryption_key, + generate_encryption_key, + validate_encryption_key, +) KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" @@ -42,3 +46,11 @@ def test_validate_encryption_key_rejects_all_zeros() -> None: key, so it never reaches a build.""" with pytest.raises(cv.Invalid, match="all-zeros key is reserved"): validate_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + + +def test_generate_encryption_key_passes_the_validator() -> None: + """A fresh key is 32 random bytes in the base64 form the yaml takes.""" + key = generate_encryption_key() + assert validate_encryption_key(key) == key + assert len(decode_encryption_key(key)) == 32 + assert key != generate_encryption_key() From bdd2dcd8c6f5af6fa7a2938a7ca38798b6a43020 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:39:08 +1300 Subject: [PATCH 246/268] [weikai] Mark configurable classes as final (#17292) Co-authored-by: J. Nick Koston --- esphome/components/weikai/weikai.cpp | 4 ++-- esphome/components/weikai/weikai.h | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index 2576856e2f..938b191756 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -254,7 +254,7 @@ size_t WeikaiGPIOPin::dump_summary(char *buffer, size_t len) const { void WeikaiChannel::setup_channel() { ESP_LOGCONFIG(TAG, " Setting up UART %s:%s", this->parent_->get_name(), this->get_channel_name()); // we enable transmit and receive on this channel - if (this->check_channel_down()) { + if (this->check_channel_down_()) { ESP_LOGCONFIG(TAG, " Error channel %s not working", this->get_channel_name()); } this->reset_fifo_(); @@ -378,7 +378,7 @@ size_t WeikaiChannel::rx_in_fifo_() { return available; } -bool WeikaiChannel::check_channel_down() { +bool WeikaiChannel::check_channel_down_() { // to check if we channel is up we write to the LCR W/R register // note that this will put a break on the tx line for few ms WeikaiRegister &lcr = this->reg(WKREG_LCR); diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index 02a39d3c84..eb8a41ad9d 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -266,7 +266,7 @@ class WeikaiComponent : public Component { /////////////////////////////////////////////////////////////////////////////// /// @brief Helper class to expose a WeiKai family IO pin as an internal GPIO pin. /////////////////////////////////////////////////////////////////////////////// -class WeikaiGPIOPin : public GPIOPin { +class WeikaiGPIOPin final : public GPIOPin { public: void set_parent(WeikaiComponent *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } @@ -293,7 +293,7 @@ class WeikaiGPIOPin : public GPIOPin { /// uart::UARTComponent virtual class. This class is common to the different members of the Weikai /// components family and therefore avoid code duplication. /////////////////////////////////////////////////////////////////////////////////////////////////// -class WeikaiChannel : public uart::UARTComponent { +class WeikaiChannel final : public uart::UARTComponent { public: /// @brief We belongs to this WeikaiComponent /// @param parent pointer to the component we belongs to @@ -315,10 +315,10 @@ class WeikaiChannel : public uart::UARTComponent { const char *get_channel_name() { return this->name_.c_str(); } /// @brief Setup the channel - void virtual setup_channel(); + void setup_channel(); /// @brief dump channel information - void virtual dump_channel(); + void dump_channel(); /// @brief Factory method to create a WeikaiRegister proxy object /// @param reg address of the register @@ -423,7 +423,7 @@ class WeikaiChannel : public uart::UARTComponent { /// @brief check if channel is alive /// @return true if OK - bool virtual check_channel_down(); + bool check_channel_down_(); #ifdef TEST_COMPONENT /// @ingroup test_ From f3f3ca5a8a7427f92889a5bb62ffeca6cf3cce33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:50:24 -1000 Subject: [PATCH 247/268] [runtime_image] Add CodSpeed benchmarks for JPEG decoding (#20603) --- .../components/runtime_image/__init__.py | 12 + .../runtime_image/bench_jpeg_decode.cpp | 64 ++++++ .../components/runtime_image/jpeg_96x96.h | 213 ++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 tests/benchmarks/components/runtime_image/__init__.py create mode 100644 tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp create mode 100644 tests/benchmarks/components/runtime_image/jpeg_96x96.h diff --git a/tests/benchmarks/components/runtime_image/__init__.py b/tests/benchmarks/components/runtime_image/__init__.py new file mode 100644 index 0000000000..775fda50c1 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/__init__.py @@ -0,0 +1,12 @@ +from esphome.components.runtime_image import enable_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code is suppressed in benchmark builds; the JPEG decoder is normally + # enabled by process_runtime_image_config(), so enable it here. + async def to_code_benchmark(config: ConfigType) -> None: + enable_format("JPEG") + + manifest.to_code = to_code_benchmark diff --git a/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp b/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp new file mode 100644 index 0000000000..900b340e9c --- /dev/null +++ b/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp @@ -0,0 +1,64 @@ +#include + +#include + +#include "esphome/components/runtime_image/runtime_image.h" +#include "jpeg_96x96.h" + +namespace esphome::benchmarks { + +using runtime_image::RuntimeImage; + +// A fixed-size image so every decode scales the same JPEG into the same buffer +class BenchImage : public RuntimeImage { + public: + BenchImage(image::ImageType type, int width, int height) + : RuntimeImage(runtime_image::JPEG, type, image::TRANSPARENCY_OPAQUE, nullptr, false, width, height) {} +}; + +// One full decode session, fed the way online_image feeds a download +static bool decode(BenchImage &img, std::vector &buffer) { + if (!img.begin_decode(buffer.size(), runtime_image::JPEG)) { + return false; + } + size_t offset = 0; + while (offset < buffer.size()) { + const int consumed = img.feed_data(buffer.data() + offset, buffer.size() - offset); + if (consumed <= 0) { + return false; + } + offset += consumed; + } + return img.end_decode(); +} + +static void run(benchmark::State &state, image::ImageType type, int width, int height) { + BenchImage img(type, width, height); + std::vector buffer(JPEG_96X96, JPEG_96X96 + JPEG_96X96_SIZE); + for (auto _ : state) { + if (!decode(img, buffer)) { + state.SkipWithError("decode failed"); + return; + } + benchmark::DoNotOptimize(img.get_data_start()); + } + state.SetItemsProcessed(state.iterations() * static_cast(width) * height); +} + +// --- JPEG into an RGB565 image: same size, half size and double size --- + +static void JpegDecode_Rgb565_Native(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 96, 96); } +BENCHMARK(JpegDecode_Rgb565_Native); + +static void JpegDecode_Rgb565_Half(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 48, 48); } +BENCHMARK(JpegDecode_Rgb565_Half); + +static void JpegDecode_Rgb565_Double(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 192, 192); } +BENCHMARK(JpegDecode_Rgb565_Double); + +// --- JPEG into an RGB image at the same size, the generic per-pixel path for reference --- + +static void JpegDecode_Rgb_Native(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB, 96, 96); } +BENCHMARK(JpegDecode_Rgb_Native); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/runtime_image/jpeg_96x96.h b/tests/benchmarks/components/runtime_image/jpeg_96x96.h new file mode 100644 index 0000000000..625515ec48 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/jpeg_96x96.h @@ -0,0 +1,213 @@ +#pragma once + +#include +#include + +namespace esphome::benchmarks { + +// 96x96 baseline JPEG, quality 85, no chroma subsampling: a gradient with rings, 3791 bytes +static const uint8_t JPEG_96X96[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x05, 0x03, 0x04, 0x04, 0x04, 0x03, 0x05, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, + 0x06, 0x07, 0x0C, 0x08, 0x07, 0x07, 0x07, 0x07, 0x0F, 0x0B, 0x0B, 0x09, 0x0C, 0x11, 0x0F, 0x12, 0x12, 0x11, 0x0F, + 0x11, 0x11, 0x13, 0x16, 0x1C, 0x17, 0x13, 0x14, 0x1A, 0x15, 0x11, 0x11, 0x18, 0x21, 0x18, 0x1A, 0x1D, 0x1D, 0x1F, + 0x1F, 0x1F, 0x13, 0x17, 0x22, 0x24, 0x22, 0x1E, 0x24, 0x1C, 0x1E, 0x1F, 0x1E, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x05, + 0x05, 0x05, 0x07, 0x06, 0x07, 0x0E, 0x08, 0x08, 0x0E, 0x1E, 0x14, 0x11, 0x14, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x60, 0x00, 0x60, 0x03, 0x01, 0x11, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, + 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, + 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, + 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, + 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, + 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, + 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xF9, 0x66, 0x0B, 0x6E, + 0x7A, 0x57, 0xED, 0x30, 0x8B, 0xB9, 0xE7, 0x53, 0xAA, 0x69, 0x5A, 0xDB, 0xF4, 0xE2, 0xBD, 0xEC, 0x12, 0x67, 0x5C, + 0x2A, 0x9A, 0x90, 0x41, 0x81, 0xD2, 0xBE, 0x96, 0x9C, 0xAD, 0x13, 0xAE, 0x9D, 0x40, 0xB8, 0x87, 0xE5, 0xE9, 0x5F, + 0x1B, 0xC4, 0x55, 0xD2, 0x83, 0x3D, 0x8C, 0x2C, 0xF5, 0x33, 0x9A, 0x0C, 0xC9, 0xD2, 0xBF, 0x01, 0xCD, 0x6A, 0xF3, + 0x57, 0x3E, 0x97, 0x0F, 0x53, 0x43, 0x77, 0x45, 0xB7, 0xE9, 0xC5, 0x7E, 0x99, 0xC1, 0x29, 0xE8, 0xCB, 0xA9, 0x54, + 0xD0, 0xD4, 0x21, 0xC4, 0x5D, 0x2B, 0xD5, 0xE3, 0x9A, 0xC9, 0x61, 0xDA, 0x3B, 0x70, 0x35, 0x35, 0x21, 0xD1, 0xAD, + 0xF3, 0x3F, 0x4E, 0xF5, 0xF9, 0x87, 0x06, 0x3E, 0x6C, 0x6D, 0xFC, 0xCF, 0x76, 0xA5, 0x4F, 0x74, 0xD8, 0xD5, 0x21, + 0xC4, 0x20, 0x62, 0xBE, 0xE7, 0xC4, 0x3A, 0xD6, 0xC2, 0x24, 0x6F, 0x80, 0xA9, 0xEF, 0x16, 0x3C, 0x3F, 0x6F, 0xF2, + 0x13, 0x8A, 0xF2, 0xFC, 0x39, 0x6D, 0x51, 0x93, 0x3D, 0x4A, 0xF5, 0x35, 0x40, 0xD0, 0x67, 0x52, 0x1C, 0x77, 0xAF, + 0x8F, 0xCC, 0xEA, 0x73, 0xF1, 0x0A, 0xB7, 0x73, 0xD8, 0xC3, 0xD4, 0xB5, 0x23, 0x53, 0x56, 0x83, 0xF7, 0x28, 0x31, + 0x5F, 0x47, 0xE2, 0x45, 0x4B, 0x61, 0x29, 0x47, 0xD0, 0xEA, 0xCB, 0xEA, 0x7B, 0xCC, 0xF2, 0xB8, 0x6D, 0x39, 0xE9, + 0x5F, 0xAD, 0xC3, 0x09, 0xA9, 0xFC, 0x5D, 0x4E, 0xB9, 0xA1, 0x6D, 0x6B, 0x8C, 0x71, 0x5E, 0xB6, 0x1E, 0x87, 0x29, + 0xD9, 0x0A, 0xC5, 0xE4, 0x87, 0x0B, 0xD2, 0xBA, 0x6B, 0xD6, 0x54, 0xE0, 0x77, 0xD1, 0xA9, 0x72, 0x0B, 0x94, 0xE2, + 0xBF, 0x2D, 0xE2, 0x7C, 0xC9, 0x28, 0xB5, 0x73, 0xDE, 0xC2, 0x48, 0xA9, 0x14, 0x1B, 0xA4, 0xE9, 0x5F, 0x8E, 0x4E, + 0xA3, 0xAF, 0x88, 0xD0, 0xFA, 0x0A, 0x55, 0x2C, 0x8E, 0x97, 0x48, 0xB5, 0xC2, 0x03, 0x8A, 0xFD, 0xBB, 0x84, 0x70, + 0xFE, 0xCE, 0x92, 0x93, 0x14, 0xEB, 0x5D, 0x8F, 0xD4, 0xE3, 0xE3, 0x15, 0xF3, 0xDC, 0x7F, 0x8F, 0x4A, 0x2E, 0x37, + 0x3D, 0x5C, 0x04, 0x89, 0x74, 0x2B, 0x5C, 0xC9, 0x9C, 0x57, 0x81, 0xC0, 0x14, 0x1C, 0xEB, 0x73, 0x9E, 0xB5, 0x6A, + 0xDA, 0x58, 0xBF, 0xAB, 0x45, 0xC0, 0x5C, 0x57, 0x77, 0x89, 0x38, 0xC5, 0xCA, 0xA9, 0xA3, 0xB7, 0x2F, 0x9F, 0x52, + 0xFE, 0x89, 0x6D, 0xB6, 0xD8, 0xB6, 0x3B, 0x57, 0xA1, 0xC0, 0xD0, 0xF6, 0x39, 0x74, 0xAA, 0x3E, 0xC7, 0x75, 0x4A, + 0xB7, 0x9D, 0x86, 0x5B, 0x41, 0xE6, 0x6A, 0x5D, 0x3B, 0xD7, 0xE7, 0xF8, 0x59, 0x7D, 0x6B, 0x88, 0xAE, 0xBB, 0x9E, + 0xDC, 0x2A, 0x72, 0xD1, 0x34, 0xB5, 0x68, 0x7E, 0x68, 0xD3, 0x15, 0xED, 0x78, 0x93, 0x88, 0xBD, 0x4A, 0x54, 0x57, + 0x91, 0xD9, 0x97, 0x4F, 0x46, 0xCF, 0x37, 0x8A, 0xD8, 0x7A, 0x57, 0xF4, 0x83, 0x84, 0x51, 0xFC, 0x4B, 0x4A, 0xA9, + 0x6A, 0x38, 0x40, 0x1D, 0x2B, 0x1A, 0xB8, 0x88, 0x53, 0x47, 0xA3, 0x4A, 0x6D, 0x8B, 0x20, 0x00, 0x57, 0xC7, 0xE7, + 0x19, 0xD4, 0x69, 0xC5, 0xEA, 0x7A, 0xF8, 0x67, 0x72, 0x94, 0xCB, 0xB8, 0xE2, 0xBF, 0x15, 0xCF, 0xB3, 0x77, 0x5A, + 0x4D, 0x26, 0x7D, 0x16, 0x19, 0xD8, 0xB5, 0xA7, 0x59, 0x97, 0x70, 0x71, 0x5C, 0xD9, 0x06, 0x06, 0x58, 0x8A, 0xC9, + 0xD8, 0xF4, 0xBD, 0xBD, 0x91, 0xD3, 0xDA, 0xDB, 0x08, 0xE1, 0xE9, 0xDA, 0xBF, 0x79, 0xC2, 0x46, 0x38, 0x2C, 0x25, + 0xDF, 0x62, 0x21, 0x57, 0x9A, 0x46, 0x7D, 0xE2, 0x79, 0x92, 0xED, 0x15, 0xF8, 0x3F, 0x17, 0x66, 0x4F, 0x15, 0x8A, + 0xE4, 0x4C, 0xFA, 0x3C, 0x1C, 0xB9, 0x63, 0x73, 0x6F, 0x45, 0xB3, 0xD9, 0x16, 0xE2, 0x2B, 0xF4, 0x3E, 0x09, 0xC1, + 0xFD, 0x5B, 0x0B, 0xED, 0x64, 0x6B, 0x52, 0xBD, 0xE5, 0x62, 0x2B, 0xE8, 0xFC, 0xCB, 0x90, 0xA0, 0x77, 0xAF, 0xCE, + 0x78, 0xCB, 0x1C, 0xF1, 0x99, 0x8A, 0xA7, 0x1E, 0xE7, 0xB9, 0x82, 0x97, 0x2C, 0x2E, 0x6E, 0x5B, 0xDB, 0x88, 0x6C, + 0x3A, 0x76, 0xAF, 0xD1, 0xE8, 0xC9, 0x65, 0xB9, 0x1B, 0x6F, 0x4D, 0x0D, 0x69, 0xD4, 0xE7, 0xAA, 0x33, 0x43, 0xB5, + 0xDF, 0x74, 0x5C, 0x8A, 0xFC, 0xF3, 0x81, 0x69, 0x3C, 0x56, 0x65, 0x3A, 0xEC, 0xF6, 0xEA, 0xD6, 0xE5, 0x82, 0x45, + 0xAB, 0xB8, 0x7C, 0xDD, 0x41, 0x10, 0x0E, 0x86, 0xB1, 0xE2, 0xBC, 0x47, 0xD7, 0xB3, 0xE8, 0x51, 0x5D, 0x19, 0xE8, + 0xE0, 0xE7, 0xCB, 0x4A, 0xE7, 0x98, 0xA3, 0x0C, 0x57, 0xEE, 0xB5, 0xB8, 0x92, 0x09, 0x6E, 0x7F, 0x14, 0xD2, 0x4C, + 0x79, 0x71, 0x5F, 0x33, 0x98, 0xF1, 0x44, 0x52, 0x76, 0x67, 0xAF, 0x42, 0x24, 0x4E, 0x4B, 0x57, 0xE7, 0x19, 0xAE, + 0x7F, 0x2A, 0xCD, 0xA4, 0xCF, 0x6F, 0x0F, 0xA1, 0x2D, 0xAD, 0xA3, 0x48, 0xC3, 0x8A, 0xF0, 0xB0, 0x98, 0x5A, 0xB8, + 0xCA, 0xA7, 0xAB, 0x0A, 0xCA, 0x28, 0xE8, 0xF4, 0xBD, 0x3F, 0x62, 0x82, 0x56, 0xBF, 0x68, 0xE1, 0xAC, 0x95, 0x61, + 0xE0, 0xA7, 0x24, 0x0F, 0x13, 0x72, 0xDD, 0xE6, 0x11, 0x36, 0x8A, 0x8E, 0x2D, 0xCF, 0x61, 0x85, 0xA2, 0xE1, 0x16, + 0x7A, 0x18, 0x3F, 0x79, 0xDC, 0xA9, 0x65, 0x6A, 0xD3, 0xCE, 0x0E, 0x3B, 0xD7, 0xE3, 0x59, 0x56, 0x1E, 0x79, 0x9E, + 0x3B, 0x99, 0xF7, 0x3E, 0x85, 0x56, 0x50, 0x89, 0xD2, 0x79, 0x22, 0xDE, 0xD7, 0x18, 0xC1, 0xC5, 0x7E, 0xD7, 0x98, + 0x62, 0x61, 0x94, 0xE5, 0xB6, 0xD9, 0xD8, 0x54, 0x27, 0xED, 0x26, 0x53, 0xD3, 0xED, 0x4C, 0xF7, 0x7B, 0x88, 0xE3, + 0x35, 0xF8, 0xC6, 0x47, 0x46, 0x79, 0xAE, 0x6B, 0xED, 0x1E, 0xD7, 0x3E, 0x87, 0xDB, 0x72, 0x42, 0xC6, 0xC6, 0xA6, + 0x9B, 0x21, 0x11, 0x8A, 0xFB, 0x6E, 0x3F, 0xCC, 0xD6, 0x17, 0x07, 0x1C, 0x34, 0x1E, 0xA6, 0xF8, 0x07, 0xCD, 0x2B, + 0x96, 0xB4, 0x6B, 0x5F, 0x2A, 0xD4, 0xC8, 0x47, 0x6A, 0xCF, 0x82, 0xF0, 0xEB, 0x01, 0x96, 0x4F, 0x15, 0x3E, 0xA8, + 0xF4, 0x6A, 0x56, 0xE7, 0x9A, 0x8A, 0x1D, 0xA5, 0xDB, 0x19, 0xF5, 0x03, 0x21, 0x1C, 0x03, 0x5F, 0x25, 0xC3, 0x31, + 0x79, 0x9E, 0x7B, 0x3C, 0x44, 0xB6, 0x4C, 0xF6, 0x1D, 0x5E, 0x4A, 0x49, 0x1E, 0x2D, 0x1A, 0xB9, 0xA9, 0x9E, 0x6F, + 0x5E, 0x47, 0xF1, 0xE5, 0x26, 0x8B, 0x11, 0x40, 0xED, 0xDA, 0xB8, 0xE5, 0x53, 0x11, 0x5D, 0x9E, 0x9D, 0x2A, 0x89, + 0x1A, 0x36, 0x9A, 0x73, 0x39, 0x19, 0x5A, 0xF4, 0xF0, 0x19, 0x15, 0x6C, 0x44, 0x93, 0x68, 0xED, 0x8E, 0x25, 0x23, + 0x7B, 0x4F, 0xD3, 0x42, 0x80, 0x48, 0xAF, 0xD5, 0x32, 0x4E, 0x1C, 0x85, 0x04, 0xA5, 0x34, 0x57, 0xD6, 0xAE, 0x68, + 0x48, 0x16, 0x14, 0xC0, 0xC5, 0x7A, 0x99, 0xCE, 0x75, 0x47, 0x2F, 0xA2, 0xE3, 0x16, 0x76, 0xE1, 0xAF, 0x36, 0x67, + 0xBA, 0x34, 0xF2, 0xE0, 0x7A, 0xD7, 0xE0, 0x79, 0xB6, 0x69, 0x57, 0x35, 0xC4, 0xF2, 0xC4, 0xFA, 0x5C, 0x3C, 0x94, + 0x11, 0xBD, 0xA4, 0x69, 0xE2, 0x24, 0x0E, 0xC2, 0xBF, 0x51, 0xE1, 0x2C, 0x96, 0x38, 0x2A, 0x3E, 0xDA, 0xA2, 0x2E, + 0x78, 0x9E, 0x67, 0x64, 0x1A, 0x81, 0xDE, 0xFE, 0x5A, 0xD7, 0xC6, 0xF1, 0xCF, 0x10, 0x3C, 0x55, 0x5F, 0xAB, 0xD2, + 0x67, 0xB1, 0x81, 0xF7, 0x55, 0xD9, 0xA7, 0xA3, 0xD8, 0x88, 0x61, 0xF3, 0x18, 0x76, 0xAF, 0xA2, 0xE0, 0xDC, 0xB2, + 0x39, 0x76, 0x11, 0xE2, 0x6A, 0xEE, 0x75, 0xD4, 0xC4, 0x73, 0xCA, 0xC8, 0x63, 0xC4, 0x6E, 0xAF, 0x42, 0x81, 0x90, + 0x0D, 0x7E, 0x7D, 0x9B, 0x62, 0xA7, 0x9E, 0xE7, 0x0A, 0x9C, 0x75, 0x49, 0x9E, 0xDE, 0x1E, 0x6A, 0x95, 0x3B, 0x9A, + 0xF7, 0x91, 0x88, 0x2D, 0x16, 0x25, 0x1C, 0x91, 0x5F, 0x67, 0xC5, 0xF8, 0xF8, 0xE5, 0x39, 0x54, 0x70, 0xB4, 0xF7, + 0x68, 0xDF, 0x07, 0x2F, 0x69, 0x3E, 0x66, 0x59, 0xD2, 0x2D, 0x44, 0x16, 0x6D, 0x33, 0x0C, 0x12, 0x2B, 0x8B, 0x84, + 0xA8, 0x2C, 0xAF, 0x29, 0xA9, 0x8C, 0xA9, 0xA3, 0x68, 0xF4, 0xE7, 0x5B, 0x9E, 0xA2, 0x8A, 0x3C, 0x86, 0x1D, 0x23, + 0xDA, 0xBE, 0xBA, 0x1C, 0x16, 0xAF, 0xAA, 0x3F, 0x8D, 0x69, 0xE3, 0x4D, 0x0B, 0x6D, 0x29, 0x46, 0x32, 0x2B, 0xD6, + 0xC2, 0xF0, 0x95, 0x2A, 0x7A, 0xC9, 0x1D, 0x90, 0xC6, 0x36, 0x69, 0xC1, 0x69, 0x1C, 0x63, 0xA0, 0xAF, 0x76, 0x18, + 0x6C, 0x26, 0x0A, 0x37, 0x76, 0x3B, 0x29, 0xD6, 0x94, 0x87, 0x4B, 0x2A, 0xA0, 0xC2, 0xD7, 0xCC, 0x67, 0x7C, 0x5F, + 0x47, 0x0B, 0x07, 0x18, 0x33, 0xD6, 0xC3, 0x41, 0xC9, 0xEA, 0x54, 0x22, 0x49, 0xDF, 0x00, 0x1A, 0xFC, 0x67, 0x32, + 0xCE, 0x31, 0x39, 0xAD, 0x5E, 0x58, 0x1F, 0x43, 0x87, 0x71, 0x82, 0x36, 0x34, 0x9D, 0x2F, 0x18, 0x77, 0x15, 0xF7, + 0x5C, 0x29, 0xC2, 0xDC, 0x96, 0xAD, 0x59, 0x1B, 0xCB, 0x17, 0x7D, 0x11, 0xA1, 0x76, 0xEB, 0x14, 0x7B, 0x12, 0xBD, + 0x1E, 0x2D, 0xE2, 0x6A, 0x58, 0x0A, 0x0E, 0x85, 0x17, 0xA9, 0xDB, 0x83, 0x8B, 0x93, 0xBB, 0x19, 0xA5, 0x58, 0x34, + 0xF3, 0x79, 0x8C, 0x38, 0xAF, 0xCF, 0xB8, 0x5F, 0x27, 0xAB, 0x9A, 0xE2, 0xFE, 0xB1, 0x57, 0x63, 0xDB, 0x96, 0x25, + 0x42, 0x36, 0x46, 0xAD, 0xF9, 0x11, 0x44, 0x22, 0x4E, 0xB5, 0xF5, 0xBC, 0x6B, 0x9F, 0xC3, 0x01, 0x86, 0x58, 0x4A, + 0x0F, 0x5D, 0x8E, 0x9C, 0x17, 0xBC, 0xF9, 0x99, 0x63, 0x44, 0xB1, 0xD8, 0xA6, 0x79, 0x05, 0x79, 0x9C, 0x0F, 0x94, + 0xAC, 0x3D, 0x29, 0x63, 0xF1, 0x1E, 0xA7, 0xA7, 0x53, 0x11, 0x77, 0xCA, 0x87, 0x98, 0x5A, 0xF2, 0xF8, 0x28, 0x19, + 0x50, 0x6B, 0xE6, 0x73, 0x1C, 0x4D, 0x4E, 0x23, 0xCE, 0x95, 0x38, 0xEB, 0x14, 0xCF, 0x5E, 0x84, 0xD5, 0x1A, 0x77, + 0x34, 0x75, 0x04, 0x09, 0x12, 0x5B, 0x20, 0xE4, 0xD7, 0xB7, 0xC7, 0x39, 0x8A, 0xC2, 0xE1, 0xA9, 0xE5, 0x98, 0x7D, + 0xDE, 0x87, 0x66, 0x06, 0x5C, 0xD2, 0xE7, 0x67, 0x94, 0xC7, 0x72, 0x9E, 0xD5, 0xFA, 0x85, 0x5E, 0x3A, 0xC3, 0xA5, + 0xA3, 0x3F, 0x8B, 0xE9, 0x52, 0x64, 0x9F, 0x6A, 0x1D, 0xAB, 0xE7, 0xF1, 0xBE, 0x20, 0xC5, 0x2F, 0x75, 0x9E, 0xAD, + 0x0A, 0x22, 0x19, 0xA4, 0x7E, 0x00, 0x35, 0xF0, 0xD9, 0x8F, 0x18, 0x62, 0xB1, 0x4D, 0xA8, 0x1E, 0xD5, 0x08, 0x46, + 0x3B, 0x93, 0x5B, 0x58, 0xCB, 0x33, 0x0C, 0x83, 0x5E, 0x56, 0x1B, 0x2B, 0xC7, 0x66, 0x75, 0x2F, 0x2B, 0x9E, 0x9C, + 0x31, 0x11, 0x82, 0x37, 0xB4, 0xFD, 0x29, 0x23, 0x01, 0x9C, 0x0A, 0xFD, 0x47, 0x23, 0xE1, 0x3A, 0x38, 0x28, 0xAA, + 0x95, 0x8D, 0x3E, 0xB8, 0xE5, 0xA2, 0x2D, 0x5C, 0x4A, 0x91, 0x2E, 0xC4, 0xEB, 0x58, 0xF1, 0x27, 0x17, 0xD0, 0xC0, + 0x52, 0x74, 0x68, 0x3D, 0x4F, 0x47, 0x09, 0x07, 0x27, 0x76, 0x43, 0x67, 0x65, 0x25, 0xD4, 0xBB, 0x98, 0x71, 0x5F, + 0x98, 0x65, 0xB9, 0x7E, 0x2F, 0x3F, 0xC5, 0xFB, 0x4A, 0x9B, 0x1E, 0xEC, 0x6B, 0xC6, 0x94, 0x6C, 0x8D, 0xFF, 0x00, + 0x2E, 0x3B, 0x3B, 0x7C, 0x0C, 0x67, 0x15, 0xFA, 0x96, 0x3B, 0x1B, 0x85, 0xE1, 0xBC, 0x0F, 0x24, 0x7E, 0x2B, 0x17, + 0x42, 0x52, 0xAD, 0x2B, 0x95, 0xEC, 0x6C, 0xDE, 0xF2, 0xE7, 0x7B, 0x0F, 0x96, 0xBF, 0x2D, 0xCA, 0x30, 0x58, 0x8E, + 0x22, 0xCC, 0x3D, 0xBD, 0x5F, 0x86, 0xE7, 0xBA, 0xAB, 0xAA, 0x50, 0xB2, 0x35, 0x6F, 0xB1, 0x14, 0x42, 0x08, 0xC7, + 0x3D, 0x2B, 0xEA, 0x38, 0xCF, 0x3E, 0x86, 0x03, 0x0C, 0xB2, 0xFC, 0x2E, 0xEF, 0x4D, 0x0E, 0xAC, 0x15, 0xE4, 0xF9, + 0xE4, 0x5B, 0xD2, 0xEC, 0xD6, 0xDA, 0xD8, 0xCF, 0x20, 0xF9, 0x88, 0xA8, 0xE1, 0x8C, 0x0C, 0x32, 0x4C, 0xBE, 0x58, + 0xFC, 0x4F, 0xC4, 0xD1, 0xE8, 0xCA, 0xBB, 0xA9, 0x35, 0x14, 0x3F, 0x4E, 0xB5, 0x37, 0x77, 0x86, 0x67, 0x1F, 0x2A, + 0x9A, 0xF9, 0xBC, 0x86, 0x85, 0x4C, 0xFF, 0x00, 0x36, 0x96, 0x3A, 0xB7, 0xC1, 0x16, 0x7A, 0xDE, 0xD9, 0x52, 0x82, + 0x8A, 0x3C, 0x76, 0x1D, 0x26, 0x53, 0xD8, 0xD7, 0xB8, 0xB8, 0x37, 0x1D, 0x27, 0xAD, 0xCF, 0xE3, 0x7A, 0x78, 0xD8, + 0x97, 0xED, 0xB4, 0x57, 0x38, 0xC8, 0xAF, 0x43, 0x0F, 0xC0, 0x55, 0xA6, 0xFD, 0xF3, 0xBA, 0x18, 0xF4, 0x6A, 0xDA, + 0x69, 0x11, 0xA6, 0x0B, 0x0A, 0xFA, 0x9C, 0x17, 0x05, 0xE1, 0x70, 0xDE, 0xF5, 0x43, 0xAA, 0x18, 0xD6, 0xF6, 0x34, + 0x11, 0x20, 0x81, 0x78, 0x03, 0x35, 0xE9, 0xD7, 0xCC, 0x72, 0xDC, 0xA6, 0x1A, 0x5A, 0xE8, 0xED, 0xA3, 0x29, 0xD4, + 0x64, 0x53, 0x5C, 0xB3, 0x9D, 0xB1, 0x8A, 0xFC, 0xCF, 0x3E, 0xE3, 0xAA, 0xB8, 0xA6, 0xE9, 0x61, 0xCF, 0x73, 0x0B, + 0x49, 0x47, 0x56, 0x4D, 0x61, 0xA6, 0x4B, 0x3B, 0x86, 0x90, 0x1C, 0x57, 0x95, 0x92, 0xF0, 0xCE, 0x2F, 0x35, 0xAA, + 0xAA, 0xE2, 0x36, 0x3D, 0x4F, 0xAD, 0xC6, 0x0A, 0xC8, 0xDB, 0x11, 0xC3, 0x67, 0x16, 0x06, 0x33, 0x5F, 0xA2, 0x63, + 0x33, 0x0C, 0x0F, 0x0D, 0xE1, 0x79, 0x61, 0x6E, 0x63, 0x5A, 0x12, 0x95, 0x69, 0x6A, 0x57, 0x86, 0xDE, 0x5B, 0xE9, + 0xFA, 0x1D, 0xB9, 0xAF, 0xCA, 0x23, 0xF5, 0xDE, 0x28, 0xC6, 0xDD, 0xDF, 0x92, 0xE7, 0xBF, 0x4E, 0xAC, 0x68, 0x44, + 0xDB, 0x31, 0xC7, 0x63, 0x6F, 0xB5, 0x47, 0xCD, 0x8A, 0xFD, 0x07, 0x30, 0xC7, 0xE1, 0x38, 0x5F, 0x01, 0xEC, 0xA9, + 0xFC, 0x6D, 0x1A, 0xD0, 0x94, 0xAB, 0xCA, 0xEC, 0x4D, 0x2E, 0xC1, 0xAE, 0x26, 0xF3, 0xE5, 0x07, 0x68, 0xE7, 0x9A, + 0xF8, 0xAE, 0x19, 0xCA, 0x2A, 0xE6, 0xF8, 0xB7, 0x98, 0x62, 0xFE, 0x15, 0xAE, 0xA7, 0xB2, 0xF1, 0x0A, 0x9C, 0x79, + 0x22, 0x5C, 0xBB, 0x06, 0xE2, 0x65, 0xB7, 0x84, 0x7C, 0xA3, 0x83, 0x8A, 0x5C, 0x57, 0x9C, 0xCF, 0x37, 0xC5, 0x47, + 0x2D, 0xC1, 0xFC, 0x2B, 0x4D, 0x0F, 0x43, 0x06, 0xD5, 0x38, 0xF3, 0xC8, 0xD0, 0x78, 0x96, 0xCA, 0xD0, 0x44, 0x83, + 0xE7, 0x6A, 0xF4, 0xF3, 0x7C, 0x5D, 0x2E, 0x19, 0xCA, 0x56, 0x12, 0x8F, 0xF1, 0x24, 0x8E, 0xDC, 0x3C, 0xDD, 0x6A, + 0x9C, 0xCF, 0x63, 0xC8, 0xE3, 0xBB, 0x8B, 0xB0, 0x15, 0xF7, 0xB5, 0x3C, 0x44, 0xC2, 0x25, 0xA2, 0x3F, 0x8C, 0xE9, + 0x50, 0x91, 0x30, 0xBD, 0x1F, 0xC2, 0xB5, 0xE2, 0xE2, 0xFC, 0x4A, 0x5B, 0x53, 0x47, 0xA9, 0x47, 0x0F, 0xDC, 0x70, + 0x9E, 0x69, 0x38, 0x55, 0x35, 0xF2, 0x78, 0xCE, 0x34, 0xCC, 0x71, 0x8F, 0x96, 0x92, 0x67, 0xB1, 0x42, 0x9C, 0x23, + 0xB9, 0x62, 0xDB, 0x4F, 0xB8, 0x9C, 0x82, 0xC0, 0xE2, 0xB8, 0xB0, 0xF9, 0x26, 0x6B, 0x9A, 0xCE, 0xF5, 0x2F, 0x63, + 0xD2, 0x86, 0x26, 0x10, 0xD8, 0xDA, 0xB2, 0xD2, 0x62, 0x84, 0x06, 0x93, 0x15, 0xF7, 0xB9, 0x5F, 0x07, 0xE1, 0x32, + 0xE8, 0xFB, 0x5C, 0x4B, 0xD4, 0xD9, 0x63, 0x65, 0x3D, 0x22, 0x59, 0x9A, 0x78, 0xE2, 0x5D, 0x91, 0x01, 0x9F, 0x6A, + 0xE4, 0xCF, 0x78, 0xDF, 0x0D, 0x80, 0x83, 0xA1, 0x83, 0xDF, 0xC8, 0xF4, 0xB0, 0xD4, 0xE5, 0x27, 0x79, 0x0D, 0xB4, + 0xB1, 0x9A, 0xF2, 0x4D, 0xCE, 0x0E, 0xDA, 0xF8, 0x5C, 0x06, 0x53, 0x98, 0x71, 0x16, 0x23, 0xDA, 0xD7, 0xBF, 0x29, + 0xED, 0x47, 0x11, 0x0A, 0x4A, 0xC8, 0xD9, 0xD9, 0x0D, 0x8C, 0x3B, 0x54, 0x0D, 0xD5, 0xF7, 0x78, 0xDC, 0xCB, 0x01, + 0xC2, 0xF8, 0x4F, 0x67, 0x4A, 0xCE, 0x66, 0xF4, 0x25, 0x3A, 0xF2, 0xBB, 0x1B, 0x65, 0x65, 0x2D, 0xEC, 0xDE, 0x64, + 0x83, 0x09, 0x5F, 0x03, 0x96, 0x65, 0xB8, 0xCE, 0x25, 0xC6, 0x7D, 0x67, 0x13, 0xA4, 0x11, 0xED, 0x2A, 0xF1, 0xA3, + 0x1B, 0x2D, 0xCD, 0x2B, 0x96, 0x58, 0x90, 0x5B, 0xC0, 0x39, 0xE9, 0xC5, 0x7B, 0x7C, 0x51, 0xC4, 0x54, 0xF0, 0x34, + 0x56, 0x5B, 0x97, 0xFC, 0x4F, 0x4D, 0x0E, 0xCC, 0x22, 0x72, 0x7C, 0xF3, 0x2D, 0xE9, 0xF6, 0x49, 0x69, 0x01, 0x9E, + 0x60, 0x37, 0x9F, 0x5A, 0x59, 0x1E, 0x5F, 0x47, 0x87, 0xB0, 0x52, 0xCC, 0x31, 0x9F, 0x1B, 0xD5, 0x5C, 0xF4, 0x3D, + 0xBB, 0xAB, 0x2E, 0x48, 0xEC, 0x4B, 0x61, 0x68, 0xD7, 0x97, 0x26, 0xE2, 0x51, 0x88, 0xD6, 0xBC, 0x1C, 0x9F, 0x0D, + 0x57, 0x88, 0xB3, 0x09, 0x66, 0x38, 0xBD, 0x29, 0xC7, 0x54, 0x7A, 0xCA, 0xB2, 0xA3, 0x1E, 0x48, 0xEE, 0x79, 0x3C, + 0x3A, 0x1A, 0x77, 0x35, 0xFA, 0x4A, 0xF0, 0xEA, 0x8A, 0xDE, 0x47, 0xF1, 0x85, 0x3C, 0xC1, 0x97, 0x60, 0xD1, 0xE0, + 0x5F, 0xBC, 0x45, 0x75, 0x52, 0xE0, 0x8C, 0xBA, 0x8E, 0xB5, 0x24, 0x8E, 0xEA, 0x78, 0xE9, 0xBD, 0x8B, 0xD0, 0xDA, + 0xDA, 0x42, 0x3B, 0x57, 0x57, 0xB3, 0xC8, 0xF2, 0xD5, 0x76, 0xD6, 0x87, 0x7D, 0x2A, 0xB5, 0x66, 0x4A, 0x6E, 0xA2, + 0x41, 0x88, 0xD6, 0xBC, 0x1C, 0xC7, 0xC4, 0x1C, 0x1E, 0x15, 0x38, 0x61, 0xA3, 0xA9, 0xEA, 0xD0, 0xA3, 0x29, 0x7C, + 0x43, 0x55, 0x6E, 0xAE, 0x9B, 0x0A, 0x08, 0x15, 0xF0, 0xB8, 0x9C, 0xDF, 0x38, 0xCF, 0x67, 0xCB, 0x4D, 0x34, 0x99, + 0xEC, 0xD1, 0x74, 0xE9, 0x2D, 0x4D, 0x4B, 0x1D, 0x1D, 0x53, 0x12, 0x4E, 0x7F, 0x3A, 0xFA, 0x4C, 0xA3, 0x82, 0x69, + 0x61, 0xD7, 0xD6, 0x31, 0xF2, 0xFB, 0xCE, 0xB5, 0x8E, 0x6F, 0x48, 0x97, 0x25, 0x9D, 0x22, 0x5F, 0x2A, 0x05, 0xC9, + 0xF6, 0xA3, 0x3C, 0xE3, 0x4C, 0x36, 0x02, 0x1F, 0x55, 0xCB, 0xD5, 0xDE, 0xDA, 0x1E, 0x86, 0x1A, 0x9B, 0x93, 0xE6, + 0x99, 0x25, 0x86, 0x99, 0x25, 0xC3, 0xF9, 0xB3, 0xE4, 0x2F, 0x5E, 0x6B, 0xE7, 0x72, 0x7E, 0x1B, 0xC5, 0xE6, 0xF5, + 0x7E, 0xB9, 0x8F, 0x76, 0x8E, 0xFA, 0x9E, 0xB2, 0xC5, 0xC6, 0x9A, 0xE5, 0x89, 0xA1, 0x3C, 0x8B, 0x12, 0x88, 0x2D, + 0xD7, 0x9E, 0x9C, 0x57, 0xA7, 0xC4, 0x1C, 0x55, 0x47, 0x03, 0x4F, 0xFB, 0x3F, 0x2C, 0x57, 0x96, 0xDA, 0x1D, 0xB8, + 0x58, 0xB9, 0x3E, 0x79, 0x96, 0x74, 0xED, 0x3D, 0x61, 0x5F, 0xB4, 0xDC, 0xF5, 0xEB, 0xCD, 0x73, 0x64, 0x39, 0x1D, + 0x3C, 0xBA, 0x9B, 0xCC, 0xF3, 0x37, 0xEF, 0x6F, 0xA9, 0xE9, 0x7D, 0x65, 0xCD, 0xF2, 0x40, 0x99, 0x21, 0x93, 0x50, + 0xB8, 0x00, 0x02, 0x22, 0x5A, 0xF1, 0x2B, 0xD7, 0xC5, 0xF1, 0x86, 0x61, 0xC9, 0x0D, 0x28, 0xC7, 0xEE, 0x3D, 0x4A, + 0x35, 0x23, 0x87, 0x85, 0xFA, 0x97, 0xA7, 0x50, 0x8A, 0xB6, 0x76, 0xC3, 0x93, 0xC1, 0x22, 0xBB, 0x38, 0x8B, 0x37, + 0x8E, 0x1E, 0x9C, 0x72, 0x5C, 0xAF, 0x77, 0xA3, 0x68, 0xEF, 0xC2, 0xBB, 0xBF, 0x69, 0x33, 0xC6, 0xA2, 0x5B, 0xD3, + 0xFC, 0x26, 0xBD, 0x19, 0x66, 0xBC, 0x43, 0x3D, 0x2C, 0xCF, 0xE3, 0x9A, 0x4E, 0x92, 0x2D, 0x45, 0x69, 0x7B, 0x27, + 0x63, 0x58, 0xBC, 0x37, 0x11, 0x62, 0xF4, 0x77, 0x3D, 0x1A, 0x55, 0xA9, 0x44, 0xBD, 0x6F, 0xA3, 0xCE, 0xFC, 0xB9, + 0x35, 0xD3, 0x43, 0x81, 0xF3, 0x2C, 0x53, 0xBD, 0x79, 0x9D, 0xF4, 0xF1, 0xD0, 0x8E, 0xC6, 0x9D, 0xB6, 0x93, 0x6F, + 0x16, 0x0C, 0x84, 0x1A, 0xFA, 0x0C, 0x37, 0x06, 0xE5, 0x99, 0x7A, 0xE7, 0xC4, 0xCD, 0x36, 0x75, 0xC3, 0x1B, 0x39, + 0x7C, 0x25, 0xBF, 0x3A, 0xDA, 0x01, 0xB6, 0x25, 0x04, 0xD4, 0x63, 0xB8, 0xC3, 0x2A, 0xCA, 0x63, 0xC9, 0x84, 0x8A, + 0x6C, 0xF4, 0x68, 0x42, 0x73, 0xD6, 0x42, 0x22, 0x5D, 0x5E, 0x36, 0x15, 0x48, 0x5A, 0xF8, 0x9A, 0xF9, 0x96, 0x75, + 0xC4, 0x75, 0x39, 0x29, 0xA6, 0xA2, 0xCF, 0x66, 0x8C, 0xA9, 0xD1, 0x5A, 0x9A, 0x96, 0x7A, 0x64, 0x36, 0xCB, 0xE6, + 0x4E, 0x41, 0x6A, 0xFA, 0x4C, 0xBF, 0x86, 0xB2, 0xFC, 0x92, 0x1F, 0x59, 0xC7, 0xC9, 0x39, 0x1D, 0x51, 0xC5, 0xCE, + 0xA3, 0xB4, 0x09, 0x1E, 0x59, 0x2E, 0x1B, 0xC9, 0xB7, 0x4C, 0x2F, 0x4E, 0x2B, 0xE7, 0xF3, 0x7E, 0x2C, 0xC5, 0xE6, + 0xF3, 0xFA, 0x9E, 0x59, 0x1B, 0x47, 0x6D, 0x0F, 0x5B, 0x0F, 0x08, 0xD3, 0x5C, 0xD3, 0x34, 0x2C, 0xEC, 0x22, 0xB4, + 0x4F, 0x3A, 0x72, 0x0B, 0xD7, 0xA5, 0x97, 0x64, 0xB8, 0x2E, 0x1E, 0xA3, 0xF5, 0xDC, 0xC2, 0x57, 0xA9, 0xBE, 0xA7, + 0x6C, 0x71, 0x32, 0xAA, 0xF9, 0x61, 0xB1, 0x22, 0x43, 0x36, 0xA1, 0x36, 0x00, 0x2B, 0x10, 0xAF, 0x9D, 0xAD, 0x88, + 0xC7, 0xF1, 0x86, 0x2F, 0x92, 0x1E, 0xED, 0x15, 0xF9, 0x1E, 0xB5, 0x19, 0xC7, 0x0F, 0x1D, 0x77, 0x2F, 0xB8, 0x4B, + 0x68, 0xC5, 0xB5, 0xB0, 0xCB, 0x9E, 0x09, 0x15, 0xE8, 0xE6, 0xF9, 0xCD, 0x0C, 0x92, 0x82, 0xCB, 0x32, 0xC5, 0x7A, + 0x8F, 0x46, 0xD1, 0xDB, 0x87, 0xBD, 0x47, 0xCF, 0x3D, 0x8B, 0x56, 0x96, 0x89, 0x65, 0x0F, 0x9F, 0x37, 0x32, 0xB7, + 0x41, 0x58, 0x60, 0x30, 0xD4, 0x38, 0x67, 0x08, 0xF3, 0x0C, 0x73, 0xBD, 0x69, 0x6C, 0x8F, 0x4A, 0x35, 0x9D, 0x69, + 0x72, 0x47, 0x63, 0xC8, 0xE3, 0xBD, 0x8F, 0xB4, 0x5F, 0xA5, 0x7D, 0xB4, 0xFC, 0x4A, 0xC2, 0x2F, 0x86, 0x97, 0xE0, + 0x7F, 0x19, 0xD2, 0xA1, 0x2E, 0xE4, 0xE9, 0x7A, 0xC7, 0xEE, 0x45, 0xFA, 0x57, 0x9F, 0x5B, 0xC4, 0xBA, 0x8F, 0x4A, + 0x34, 0xBF, 0x03, 0xD3, 0xA3, 0x41, 0x75, 0x64, 0xE9, 0x25, 0xE4, 0xBC, 0x24, 0x64, 0x57, 0x91, 0x5B, 0x8B, 0x33, + 0xEC, 0x7F, 0xBB, 0x46, 0x0D, 0x1E, 0xA5, 0x18, 0xD2, 0x8E, 0xEC, 0xB5, 0x06, 0x99, 0x79, 0x39, 0xCC, 0x84, 0x81, + 0x58, 0xD3, 0xE1, 0xBC, 0xF7, 0x34, 0x77, 0xC4, 0x4D, 0xA4, 0x7A, 0x34, 0xF1, 0x54, 0xA1, 0xB1, 0xA5, 0x6D, 0xA5, + 0x5B, 0x40, 0x37, 0x4C, 0xC0, 0x9A, 0xF7, 0x30, 0xFC, 0x27, 0x94, 0xE5, 0x4B, 0xDA, 0x63, 0x2A, 0x26, 0xD1, 0xD9, + 0x0C, 0x64, 0xE7, 0xA4, 0x4B, 0x26, 0xE2, 0x38, 0xC6, 0xCB, 0x68, 0xF2, 0x7D, 0x85, 0x71, 0x66, 0x1C, 0x77, 0x86, + 0xC2, 0xAF, 0x61, 0x96, 0x53, 0xBB, 0xF2, 0x3D, 0x2A, 0x14, 0x9C, 0xB5, 0x9B, 0x25, 0xB6, 0xD3, 0xEE, 0x6E, 0xDB, + 0x7C, 0xC4, 0xAA, 0xD7, 0x87, 0x87, 0xC8, 0xB3, 0x6C, 0xFE, 0xA7, 0xB6, 0xC7, 0x4B, 0x96, 0x07, 0xAB, 0x0C, 0x4D, + 0x3A, 0x4A, 0xD1, 0xDC, 0xD1, 0x51, 0x6D, 0x64, 0x9B, 0x22, 0x50, 0xCF, 0x5E, 0xC6, 0x2B, 0x38, 0xCA, 0x78, 0x66, + 0x97, 0xB1, 0xC2, 0x25, 0x2A, 0x87, 0x65, 0x17, 0x3A, 0xCE, 0xF2, 0xD8, 0x92, 0xD6, 0xC2, 0x7B, 0xC7, 0xF3, 0x6E, + 0x32, 0xA9, 0xE9, 0x5F, 0x3F, 0x84, 0xCA, 0x33, 0x0E, 0x22, 0xAB, 0xF5, 0xBC, 0xC6, 0x5C, 0xB4, 0xD6, 0xB6, 0x67, + 0xAD, 0x0C, 0x44, 0x28, 0xAE, 0x58, 0x6E, 0x5E, 0x66, 0x08, 0x05, 0xB5, 0x9A, 0x64, 0xF4, 0x24, 0x57, 0x46, 0x6B, + 0xC4, 0x94, 0xF0, 0xF1, 0xFE, 0xCB, 0xC9, 0x63, 0x77, 0xB3, 0x68, 0xEF, 0xC3, 0xA6, 0xFD, 0xFA, 0x8C, 0xB7, 0x6B, + 0x67, 0x1D, 0x94, 0x7E, 0x74, 0xFF, 0x00, 0x34, 0xA7, 0xA0, 0xA5, 0x84, 0xC1, 0x61, 0x38, 0x66, 0x83, 0xC7, 0x66, + 0x0F, 0x9A, 0xB3, 0xD9, 0x1E, 0x84, 0x2B, 0xCA, 0xB3, 0xE5, 0x86, 0xC5, 0x8B, 0x4B, 0x47, 0xBA, 0x93, 0xED, 0x37, + 0x3F, 0x2C, 0x63, 0x90, 0x0D, 0x79, 0xB8, 0x2C, 0x25, 0x7C, 0xF6, 0xBB, 0xCD, 0x33, 0x47, 0xCB, 0x46, 0x3A, 0xA4, + 0xCF, 0x4E, 0x35, 0xD5, 0x25, 0xC9, 0x0D, 0xCF, 0xFF, 0xD9, +}; +static constexpr size_t JPEG_96X96_SIZE = sizeof(JPEG_96X96); + +} // namespace esphome::benchmarks From 39a7f4482ae097afbb039ca6bb4a950a0cd02197 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:52:16 -1000 Subject: [PATCH 248/268] [bh1745] Use SUB_SENSOR for the sensors (#20606) --- esphome/components/bh1745/bh1745.h | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/esphome/components/bh1745/bh1745.h b/esphome/components/bh1745/bh1745.h index 06d0987d67..6463aab38d 100644 --- a/esphome/components/bh1745/bh1745.h +++ b/esphome/components/bh1745/bh1745.h @@ -22,6 +22,13 @@ enum class AdcGain : uint8_t { }; class BH1745Component : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(red_counts) + SUB_SENSOR(green_counts) + SUB_SENSOR(blue_counts) + SUB_SENSOR(clear_counts) + SUB_SENSOR(illuminance) + SUB_SENSOR(color_temperature) + public: void setup() override; void dump_config() override; @@ -31,25 +38,11 @@ class BH1745Component : public PollingComponent, public i2c::I2CDevice { void set_adc_gain(AdcGain adc_gain) { this->adc_gain_ = adc_gain; } void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; } - void set_red_counts_sensor(sensor::Sensor *red) { this->red_counts_sensor_ = red; } - void set_green_counts_sensor(sensor::Sensor *green) { this->green_counts_sensor_ = green; } - void set_blue_counts_sensor(sensor::Sensor *blue) { this->blue_counts_sensor_ = blue; } - void set_clear_counts_sensor(sensor::Sensor *clear) { this->clear_counts_sensor_ = clear; } - void set_illuminance_sensor(sensor::Sensor *illuminance) { this->illuminance_sensor_ = illuminance; } - void set_color_temperature_sensor(sensor::Sensor *cct) { this->color_temperature_sensor_ = cct; } - /// Called when an output platform uses the INT pin, so setup() pins the interrupt thresholds. void enable_interrupt_output() { this->interrupt_output_ = true; } void set_interrupt_state(bool on); protected: - sensor::Sensor *red_counts_sensor_{nullptr}; - sensor::Sensor *green_counts_sensor_{nullptr}; - sensor::Sensor *blue_counts_sensor_{nullptr}; - sensor::Sensor *clear_counts_sensor_{nullptr}; - sensor::Sensor *illuminance_sensor_{nullptr}; - sensor::Sensor *color_temperature_sensor_{nullptr}; - float glass_attenuation_factor_{1.0f}; float lux_scale_{1.0f}; // 160 / (gain * integration time * glass attenuation), set in setup() MeasurementTime measurement_time_{MeasurementTime::MEASUREMENT_TIME_160MS}; From c3c235dea7c2f46611789d507db3bd4231b3924c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:56:58 -1000 Subject: [PATCH 249/268] [pzem6l24] Use SUB_SENSOR and explicit sub_sensors calls for the sensors (#20609) --- esphome/components/pzem6l24/pzem6l24.cpp | 140 +++++++++++----------- esphome/components/pzem6l24/pzem6l24.h | 141 +++++++---------------- esphome/components/pzem6l24/sensor.py | 42 ++++++- 3 files changed, 150 insertions(+), 173 deletions(-) diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp index 41524eebd2..57331a639c 100644 --- a/esphome/components/pzem6l24/pzem6l24.cpp +++ b/esphome/components/pzem6l24/pzem6l24.cpp @@ -172,52 +172,52 @@ void PZEM6L24::publish_(const uint8_t *data) { // every sensor from flash. static constexpr SensorEntry SENSORS[] PROGMEM = { // Voltages (×0.1 V) - {&PZEM6L24::voltage_a_, 0, REG_U16, SCALE_DECI}, - {&PZEM6L24::voltage_b_, 2, REG_U16, SCALE_DECI}, - {&PZEM6L24::voltage_c_, 4, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_a_sensor_, 0, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_b_sensor_, 2, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_c_sensor_, 4, REG_U16, SCALE_DECI}, // Currents (×0.01 A) - {&PZEM6L24::current_a_, 6, REG_U16, SCALE_CENTI}, - {&PZEM6L24::current_b_, 8, REG_U16, SCALE_CENTI}, - {&PZEM6L24::current_c_, 10, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_a_sensor_, 6, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_b_sensor_, 8, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_c_sensor_, 10, REG_U16, SCALE_CENTI}, // Frequency (×0.01 Hz) - {&PZEM6L24::frequency_, 12, REG_U16, SCALE_CENTI}, + {&PZEM6L24::frequency_sensor_, 12, REG_U16, SCALE_CENTI}, // Active powers (×0.1 W, signed) - {&PZEM6L24::active_power_a_, 28, REG_I32, SCALE_DECI}, - {&PZEM6L24::active_power_b_, 32, REG_I32, SCALE_DECI}, - {&PZEM6L24::active_power_c_, 36, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_active_power_, 64, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_a_sensor_, 28, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_b_sensor_, 32, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_c_sensor_, 36, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_active_power_sensor_, 64, REG_I32, SCALE_DECI}, // Reactive powers (×0.1 var, signed) - {&PZEM6L24::reactive_power_a_, 40, REG_I32, SCALE_DECI}, - {&PZEM6L24::reactive_power_b_, 44, REG_I32, SCALE_DECI}, - {&PZEM6L24::reactive_power_c_, 48, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_reactive_power_, 68, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_a_sensor_, 40, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_b_sensor_, 44, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_c_sensor_, 48, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_reactive_power_sensor_, 68, REG_I32, SCALE_DECI}, // Apparent powers (×0.1 VA, signed) - {&PZEM6L24::apparent_power_a_, 52, REG_I32, SCALE_DECI}, - {&PZEM6L24::apparent_power_b_, 56, REG_I32, SCALE_DECI}, - {&PZEM6L24::apparent_power_c_, 60, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_apparent_power_, 72, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_a_sensor_, 52, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_b_sensor_, 56, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_c_sensor_, 60, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_apparent_power_sensor_, 72, REG_I32, SCALE_DECI}, // Power factors (×0.01), packed two per register: // register 0x0026 (bytes 76/77): lo-byte = phase B, hi-byte = phase A // register 0x0027 (bytes 78/79): lo-byte = combined, hi-byte = phase C - {&PZEM6L24::power_factor_a_, 77, REG_U8, SCALE_CENTI}, - {&PZEM6L24::power_factor_b_, 76, REG_U8, SCALE_CENTI}, - {&PZEM6L24::power_factor_c_, 79, REG_U8, SCALE_CENTI}, - {&PZEM6L24::total_power_factor_, 78, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_a_sensor_, 77, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_b_sensor_, 76, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_c_sensor_, 79, REG_U8, SCALE_CENTI}, + {&PZEM6L24::total_power_factor_sensor_, 78, REG_U8, SCALE_CENTI}, // Active energies (×0.1 kWh, unsigned) - {&PZEM6L24::active_energy_a_, 80, REG_U32, SCALE_DECI}, - {&PZEM6L24::active_energy_b_, 84, REG_U32, SCALE_DECI}, - {&PZEM6L24::active_energy_c_, 88, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_active_energy_, 116, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_a_sensor_, 80, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_b_sensor_, 84, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_c_sensor_, 88, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_active_energy_sensor_, 116, REG_U32, SCALE_DECI}, // Reactive energies (×0.1 kvarh, unsigned) - {&PZEM6L24::reactive_energy_a_, 92, REG_U32, SCALE_DECI}, - {&PZEM6L24::reactive_energy_b_, 96, REG_U32, SCALE_DECI}, - {&PZEM6L24::reactive_energy_c_, 100, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_reactive_energy_, 120, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_a_sensor_, 92, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_b_sensor_, 96, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_c_sensor_, 100, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_reactive_energy_sensor_, 120, REG_U32, SCALE_DECI}, // Apparent energies (×0.1 kVAh, unsigned) - {&PZEM6L24::apparent_energy_a_, 104, REG_U32, SCALE_DECI}, - {&PZEM6L24::apparent_energy_b_, 108, REG_U32, SCALE_DECI}, - {&PZEM6L24::apparent_energy_c_, 112, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_apparent_energy_, 124, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_a_sensor_, 104, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_b_sensor_, 108, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_c_sensor_, 112, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_apparent_energy_sensor_, 124, REG_U32, SCALE_DECI}, }; for (const SensorEntry &flash_entry : SENSORS) { @@ -268,41 +268,41 @@ void PZEM6L24::dump_config() { " Address: 0x%02X", this->address_); LOG_UPDATE_INTERVAL(this); - LOG_SENSOR(" ", "Voltage A", this->voltage_a_); - LOG_SENSOR(" ", "Voltage B", this->voltage_b_); - LOG_SENSOR(" ", "Voltage C", this->voltage_c_); - LOG_SENSOR(" ", "Current A", this->current_a_); - LOG_SENSOR(" ", "Current B", this->current_b_); - LOG_SENSOR(" ", "Current C", this->current_c_); - LOG_SENSOR(" ", "Active Power A", this->active_power_a_); - LOG_SENSOR(" ", "Active Power B", this->active_power_b_); - LOG_SENSOR(" ", "Active Power C", this->active_power_c_); - LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_); - LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_); - LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_); - LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_); - LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_); - LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_); - LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_); - LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_); - LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_); - LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_); - LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_); - LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_); - LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_); - LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_); - LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_); - LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_); - LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_); - LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_); - LOG_SENSOR(" ", "Frequency", this->frequency_); - LOG_SENSOR(" ", "Total Active Power", this->total_active_power_); - LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_); - LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_); - LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_); - LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_); - LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_); - LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_); + LOG_SENSOR(" ", "Voltage A", this->voltage_a_sensor_); + LOG_SENSOR(" ", "Voltage B", this->voltage_b_sensor_); + LOG_SENSOR(" ", "Voltage C", this->voltage_c_sensor_); + LOG_SENSOR(" ", "Current A", this->current_a_sensor_); + LOG_SENSOR(" ", "Current B", this->current_b_sensor_); + LOG_SENSOR(" ", "Current C", this->current_c_sensor_); + LOG_SENSOR(" ", "Active Power A", this->active_power_a_sensor_); + LOG_SENSOR(" ", "Active Power B", this->active_power_b_sensor_); + LOG_SENSOR(" ", "Active Power C", this->active_power_c_sensor_); + LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_sensor_); + LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_sensor_); + LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_sensor_); + LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_sensor_); + LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_sensor_); + LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_sensor_); + LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_sensor_); + LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_sensor_); + LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_sensor_); + LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_sensor_); + LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_sensor_); + LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_sensor_); + LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_sensor_); + LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_sensor_); + LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_sensor_); + LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_sensor_); + LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_sensor_); + LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_sensor_); + LOG_SENSOR(" ", "Frequency", this->frequency_sensor_); + LOG_SENSOR(" ", "Total Active Power", this->total_active_power_sensor_); + LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_sensor_); + LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_sensor_); + LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_sensor_); + LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_); + LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_sensor_); + LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_sensor_); } // Blank after MAX_CONSECUTIVE_READ_FAILURES; stays blanked until a poll succeeds. diff --git a/esphome/components/pzem6l24/pzem6l24.h b/esphome/components/pzem6l24/pzem6l24.h index bf13f25053..9d8351f6f7 100644 --- a/esphome/components/pzem6l24/pzem6l24.h +++ b/esphome/components/pzem6l24/pzem6l24.h @@ -36,54 +36,51 @@ constexpr std::array build_reset_pdu(ResetPhase phase) { } class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevice { + protected: + // Ahead of the sensor pointers so the two bytes share the base class's tail padding. + // Register reads accepted by the hub but not yet resolved by a terminal callback. + uint8_t reads_outstanding_{0}; + // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. + uint8_t consecutive_failures_{0}; + public: - // Per-phase sensors; each setter is named after its config key - void set_voltage_a_sensor(sensor::Sensor *sensor) { this->voltage_a_ = sensor; } - void set_voltage_b_sensor(sensor::Sensor *sensor) { this->voltage_b_ = sensor; } - void set_voltage_c_sensor(sensor::Sensor *sensor) { this->voltage_c_ = sensor; } - - void set_current_a_sensor(sensor::Sensor *sensor) { this->current_a_ = sensor; } - void set_current_b_sensor(sensor::Sensor *sensor) { this->current_b_ = sensor; } - void set_current_c_sensor(sensor::Sensor *sensor) { this->current_c_ = sensor; } - - void set_active_power_a_sensor(sensor::Sensor *sensor) { this->active_power_a_ = sensor; } - void set_active_power_b_sensor(sensor::Sensor *sensor) { this->active_power_b_ = sensor; } - void set_active_power_c_sensor(sensor::Sensor *sensor) { this->active_power_c_ = sensor; } - - void set_reactive_power_a_sensor(sensor::Sensor *sensor) { this->reactive_power_a_ = sensor; } - void set_reactive_power_b_sensor(sensor::Sensor *sensor) { this->reactive_power_b_ = sensor; } - void set_reactive_power_c_sensor(sensor::Sensor *sensor) { this->reactive_power_c_ = sensor; } - - void set_apparent_power_a_sensor(sensor::Sensor *sensor) { this->apparent_power_a_ = sensor; } - void set_apparent_power_b_sensor(sensor::Sensor *sensor) { this->apparent_power_b_ = sensor; } - void set_apparent_power_c_sensor(sensor::Sensor *sensor) { this->apparent_power_c_ = sensor; } - - void set_power_factor_a_sensor(sensor::Sensor *sensor) { this->power_factor_a_ = sensor; } - void set_power_factor_b_sensor(sensor::Sensor *sensor) { this->power_factor_b_ = sensor; } - void set_power_factor_c_sensor(sensor::Sensor *sensor) { this->power_factor_c_ = sensor; } - - void set_active_energy_a_sensor(sensor::Sensor *sensor) { this->active_energy_a_ = sensor; } - void set_active_energy_b_sensor(sensor::Sensor *sensor) { this->active_energy_b_ = sensor; } - void set_active_energy_c_sensor(sensor::Sensor *sensor) { this->active_energy_c_ = sensor; } - - void set_reactive_energy_a_sensor(sensor::Sensor *sensor) { this->reactive_energy_a_ = sensor; } - void set_reactive_energy_b_sensor(sensor::Sensor *sensor) { this->reactive_energy_b_ = sensor; } - void set_reactive_energy_c_sensor(sensor::Sensor *sensor) { this->reactive_energy_c_ = sensor; } - - void set_apparent_energy_a_sensor(sensor::Sensor *sensor) { this->apparent_energy_a_ = sensor; } - void set_apparent_energy_b_sensor(sensor::Sensor *sensor) { this->apparent_energy_b_ = sensor; } - void set_apparent_energy_c_sensor(sensor::Sensor *sensor) { this->apparent_energy_c_ = sensor; } - - // Combined sensors - void set_frequency_sensor(sensor::Sensor *sensor) { this->frequency_ = sensor; } - void set_total_active_power_sensor(sensor::Sensor *sensor) { this->total_active_power_ = sensor; } - void set_total_reactive_power_sensor(sensor::Sensor *sensor) { this->total_reactive_power_ = sensor; } - void set_total_apparent_power_sensor(sensor::Sensor *sensor) { this->total_apparent_power_ = sensor; } - void set_total_power_factor_sensor(sensor::Sensor *sensor) { this->total_power_factor_ = sensor; } - void set_total_active_energy_sensor(sensor::Sensor *sensor) { this->total_active_energy_ = sensor; } - void set_total_reactive_energy_sensor(sensor::Sensor *sensor) { this->total_reactive_energy_ = sensor; } - void set_total_apparent_energy_sensor(sensor::Sensor *sensor) { this->total_apparent_energy_ = sensor; } + SUB_SENSOR(voltage_a) + SUB_SENSOR(voltage_b) + SUB_SENSOR(voltage_c) + SUB_SENSOR(current_a) + SUB_SENSOR(current_b) + SUB_SENSOR(current_c) + SUB_SENSOR(active_power_a) + SUB_SENSOR(active_power_b) + SUB_SENSOR(active_power_c) + SUB_SENSOR(reactive_power_a) + SUB_SENSOR(reactive_power_b) + SUB_SENSOR(reactive_power_c) + SUB_SENSOR(apparent_power_a) + SUB_SENSOR(apparent_power_b) + SUB_SENSOR(apparent_power_c) + SUB_SENSOR(power_factor_a) + SUB_SENSOR(power_factor_b) + SUB_SENSOR(power_factor_c) + SUB_SENSOR(active_energy_a) + SUB_SENSOR(active_energy_b) + SUB_SENSOR(active_energy_c) + SUB_SENSOR(reactive_energy_a) + SUB_SENSOR(reactive_energy_b) + SUB_SENSOR(reactive_energy_c) + SUB_SENSOR(apparent_energy_a) + SUB_SENSOR(apparent_energy_b) + SUB_SENSOR(apparent_energy_c) + SUB_SENSOR(frequency) + SUB_SENSOR(total_active_power) + SUB_SENSOR(total_reactive_power) + SUB_SENSOR(total_apparent_power) + SUB_SENSOR(total_power_factor) + SUB_SENSOR(total_active_energy) + SUB_SENSOR(total_reactive_energy) + SUB_SENSOR(total_apparent_energy) + public: // Queues the energy reset command for the selected phase(s); the pzem6l24.reset_energy action calls this. void reset_energy(ResetPhase phase_option); @@ -104,58 +101,6 @@ class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevic void request_failed_(std::span request_pdu); void read_failed_(); void read_finished_(); - - // Register reads accepted by the hub but not yet resolved by a terminal callback. - uint8_t reads_outstanding_{0}; - // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. - uint8_t consecutive_failures_{0}; - - // Per-phase sensors - sensor::Sensor *voltage_a_{nullptr}; - sensor::Sensor *voltage_b_{nullptr}; - sensor::Sensor *voltage_c_{nullptr}; - - sensor::Sensor *current_a_{nullptr}; - sensor::Sensor *current_b_{nullptr}; - sensor::Sensor *current_c_{nullptr}; - - sensor::Sensor *active_power_a_{nullptr}; - sensor::Sensor *active_power_b_{nullptr}; - sensor::Sensor *active_power_c_{nullptr}; - - sensor::Sensor *reactive_power_a_{nullptr}; - sensor::Sensor *reactive_power_b_{nullptr}; - sensor::Sensor *reactive_power_c_{nullptr}; - - sensor::Sensor *apparent_power_a_{nullptr}; - sensor::Sensor *apparent_power_b_{nullptr}; - sensor::Sensor *apparent_power_c_{nullptr}; - - sensor::Sensor *power_factor_a_{nullptr}; - sensor::Sensor *power_factor_b_{nullptr}; - sensor::Sensor *power_factor_c_{nullptr}; - - sensor::Sensor *active_energy_a_{nullptr}; - sensor::Sensor *active_energy_b_{nullptr}; - sensor::Sensor *active_energy_c_{nullptr}; - - sensor::Sensor *reactive_energy_a_{nullptr}; - sensor::Sensor *reactive_energy_b_{nullptr}; - sensor::Sensor *reactive_energy_c_{nullptr}; - - sensor::Sensor *apparent_energy_a_{nullptr}; - sensor::Sensor *apparent_energy_b_{nullptr}; - sensor::Sensor *apparent_energy_c_{nullptr}; - - // Combined sensors - sensor::Sensor *frequency_{nullptr}; - sensor::Sensor *total_active_power_{nullptr}; - sensor::Sensor *total_reactive_power_{nullptr}; - sensor::Sensor *total_apparent_power_{nullptr}; - sensor::Sensor *total_power_factor_{nullptr}; - sensor::Sensor *total_active_energy_{nullptr}; - sensor::Sensor *total_reactive_energy_{nullptr}; - sensor::Sensor *total_apparent_energy_{nullptr}; }; } // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py index fd0430d85b..5e883acd7e 100644 --- a/esphome/components/pzem6l24/sensor.py +++ b/esphome/components/pzem6l24/sensor.py @@ -144,7 +144,7 @@ _COMBINED_QUANTITIES = { CONF_TOTAL_APPARENT_ENERGY: _APPARENT_ENERGY_SCHEMA, } -# Every sensor by config key; the C++ setter is "set__sensor". +# Every sensor by config key; each needs its sensors(...) call in to_code _SENSORS = { f"{name}_{phase}": schema for name, schema in _PHASE_QUANTITIES.items() @@ -192,7 +192,39 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - for key in _SENSORS: - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + sensors = sensor.sub_sensors(config) + await sensors("voltage_a", var.set_voltage_a_sensor) + await sensors("voltage_b", var.set_voltage_b_sensor) + await sensors("voltage_c", var.set_voltage_c_sensor) + await sensors("current_a", var.set_current_a_sensor) + await sensors("current_b", var.set_current_b_sensor) + await sensors("current_c", var.set_current_c_sensor) + await sensors("active_power_a", var.set_active_power_a_sensor) + await sensors("active_power_b", var.set_active_power_b_sensor) + await sensors("active_power_c", var.set_active_power_c_sensor) + await sensors("reactive_power_a", var.set_reactive_power_a_sensor) + await sensors("reactive_power_b", var.set_reactive_power_b_sensor) + await sensors("reactive_power_c", var.set_reactive_power_c_sensor) + await sensors("apparent_power_a", var.set_apparent_power_a_sensor) + await sensors("apparent_power_b", var.set_apparent_power_b_sensor) + await sensors("apparent_power_c", var.set_apparent_power_c_sensor) + await sensors("power_factor_a", var.set_power_factor_a_sensor) + await sensors("power_factor_b", var.set_power_factor_b_sensor) + await sensors("power_factor_c", var.set_power_factor_c_sensor) + await sensors("active_energy_a", var.set_active_energy_a_sensor) + await sensors("active_energy_b", var.set_active_energy_b_sensor) + await sensors("active_energy_c", var.set_active_energy_c_sensor) + await sensors("reactive_energy_a", var.set_reactive_energy_a_sensor) + await sensors("reactive_energy_b", var.set_reactive_energy_b_sensor) + await sensors("reactive_energy_c", var.set_reactive_energy_c_sensor) + await sensors("apparent_energy_a", var.set_apparent_energy_a_sensor) + await sensors("apparent_energy_b", var.set_apparent_energy_b_sensor) + await sensors("apparent_energy_c", var.set_apparent_energy_c_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_TOTAL_ACTIVE_POWER, var.set_total_active_power_sensor) + await sensors(CONF_TOTAL_REACTIVE_POWER, var.set_total_reactive_power_sensor) + await sensors(CONF_TOTAL_APPARENT_POWER, var.set_total_apparent_power_sensor) + await sensors(CONF_TOTAL_POWER_FACTOR, var.set_total_power_factor_sensor) + await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor) + await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor) + await sensors(CONF_TOTAL_APPARENT_ENERGY, var.set_total_apparent_energy_sensor) From afedddafe2f81821c946f04b28545655d658d763 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:57:01 -1000 Subject: [PATCH 250/268] [systa_bus] Use SUB_SENSOR and explicit sub_sensors calls for the sensors (#20610) --- .../components/systa_bus/sensor/__init__.py | 13 ++++++++----- .../systa_bus/sensor/systa_bus_sensor.h | 18 ++++++------------ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/esphome/components/systa_bus/sensor/__init__.py b/esphome/components/systa_bus/sensor/__init__.py index e4aa0684c7..d3223484ff 100644 --- a/esphome/components/systa_bus/sensor/__init__.py +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -43,7 +43,7 @@ _PUMP_SPEED_SCHEMA = sensor.sensor_schema( state_class=STATE_CLASS_MEASUREMENT, ) -# Sensors per model, keyed by config key; the C++ setter is "set__sensor" +# Sensors per model, keyed by config key MODEL_SENSORS = { CONF_SYSTASOLAR_AQUA: { CONF_TEMPERATURE_TSA: _TEMPERATURE_SCHEMA, @@ -77,10 +77,13 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - for key in MODEL_SENSORS[config[CONF_MODEL]]: - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + # The schema only lets a model's own keys through, so unconfigured ones are skipped here + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE_TSA, var.set_temperature_tsa_sensor) + await sensors(CONF_TEMPERATURE_TSE, var.set_temperature_tse_sensor) + await sensors(CONF_TEMPERATURE_TWU, var.set_temperature_twu_sensor) + await sensors(CONF_TEMPERATURE_TW2, var.set_temperature_tw2_sensor) + await sensors(CONF_PUMP_SPEED, var.set_pump_speed_sensor) 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/sensor/systa_bus_sensor.h b/esphome/components/systa_bus/sensor/systa_bus_sensor.h index 3688176d00..1b135c9666 100644 --- a/esphome/components/systa_bus/sensor/systa_bus_sensor.h +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.h @@ -8,21 +8,15 @@ namespace esphome::systa_bus { class SystaSolarAquaSensor : public SystaBusListener, public Component { + SUB_SENSOR(temperature_tsa) + SUB_SENSOR(temperature_tse) + SUB_SENSOR(temperature_twu) + SUB_SENSOR(temperature_tw2) + SUB_SENSOR(pump_speed) + public: void dump_config() override; - void set_temperature_tsa_sensor(sensor::Sensor *sensor) { this->temperature_tsa_sensor_ = sensor; } - void set_temperature_tse_sensor(sensor::Sensor *sensor) { this->temperature_tse_sensor_ = sensor; } - void set_temperature_twu_sensor(sensor::Sensor *sensor) { this->temperature_twu_sensor_ = sensor; } - void set_temperature_tw2_sensor(sensor::Sensor *sensor) { this->temperature_tw2_sensor_ = sensor; } - void set_pump_speed_sensor(sensor::Sensor *sensor) { this->pump_speed_sensor_ = sensor; } void handle_message(std::span message) override; - - protected: - sensor::Sensor *temperature_tsa_sensor_{nullptr}; - sensor::Sensor *temperature_tse_sensor_{nullptr}; - sensor::Sensor *temperature_twu_sensor_{nullptr}; - sensor::Sensor *temperature_tw2_sensor_{nullptr}; - sensor::Sensor *pump_speed_sensor_{nullptr}; }; } // namespace esphome::systa_bus From 58188305c4e3237120630762c8815b62515d3493 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:09:32 +0200 Subject: [PATCH 251/268] [uart] Keep the swapped UART0 pins on an ESP8266 reload (#20463) --- esphome/components/uart/uart_component_esp8266.cpp | 8 ++++++++ esphome/components/uart/uart_component_esp8266.h | 3 ++- tests/components/uart/test.esp8266-ard.yaml | 6 ++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0819a1bfb0..9316e90eda 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -24,6 +24,8 @@ 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) +bool ESP8266UartComponent::serial0_swapped = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + uint32_t ESP8266UartComponent::get_config() { uint32_t config = 0; @@ -111,6 +113,7 @@ void ESP8266UartComponent::setup() { this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); this->hw_serial_->swap(); ESP8266UartComponent::serial0_in_use = true; + ESP8266UartComponent::serial0_swapped = true; } else #endif // USE_ESP8266_UART_SERIAL #ifdef USE_ESP8266_UART_SERIAL1 @@ -133,6 +136,11 @@ void ESP8266UartComponent::load_settings(bool dump_config) { SerialConfig config = static_cast(get_config()); this->hw_serial_->begin(this->baud_rate_, config); this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); +#ifdef USE_ESP8266_UART_SERIAL + // begin() puts UART0 back on GPIO1/GPIO3, so swap it again like setup() did for GPIO15/GPIO13 + if (this->hw_serial_ == &Serial && ESP8266UartComponent::serial0_swapped) + this->hw_serial_->swap(); +#endif // USE_ESP8266_UART_SERIAL } else { this->sw_serial_->setup(this->tx_pin_, this->rx_pin_, this->baud_rate_, this->stop_bits_, this->data_bits_, this->parity_, this->rx_buffer_size_); diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 3d4a2ba85b..a29373a9d4 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -92,7 +92,8 @@ class ESP8266UartComponent final : public UARTComponent, public Component { ESP8266SoftwareSerial *sw_serial_{nullptr}; private: - static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static bool serial0_swapped; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) }; } // namespace esphome::uart diff --git a/tests/components/uart/test.esp8266-ard.yaml b/tests/components/uart/test.esp8266-ard.yaml index 4c9d6fd736..09d23e2925 100644 --- a/tests/components/uart/test.esp8266-ard.yaml +++ b/tests/components/uart/test.esp8266-ard.yaml @@ -7,6 +7,8 @@ esphome: - uart.write: id: uart_id data: [0x00, 0x20, 0x42] + - lambda: |- + id(uart_swapped).load_settings(false); uart: - id: uart_id @@ -23,6 +25,10 @@ uart: baud_rate: 115200 debug: debug_prefix: "[UART1] " + - id: uart_swapped + tx_pin: 15 + rx_pin: 13 + baud_rate: 9600 switch: - platform: uart From 54f228766cc0ed6bd86340fbf903eb0b44f33a99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:12:39 -1000 Subject: [PATCH 252/268] [stcc4] Use SUB_SENSOR for the output sensors (#20605) --- esphome/components/stcc4/stcc4.h | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/stcc4/stcc4.h b/esphome/components/stcc4/stcc4.h index ad25130ec7..1b8e02accc 100644 --- a/esphome/components/stcc4/stcc4.h +++ b/esphome/components/stcc4/stcc4.h @@ -12,14 +12,15 @@ enum class MeasurementMode : uint8_t { }; class STCC4Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { + SUB_SENSOR(co2) + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: void setup() override; void dump_config() override; void update() override; - void set_co2_sensor(sensor::Sensor *co2) { this->co2_sensor_ = co2; } - void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } - void set_humidity_sensor(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } void set_temperature_source(sensor::Sensor *temperature) { this->temperature_source_ = temperature; } void set_humidity_source(sensor::Sensor *humidity) { this->humidity_source_ = humidity; } void set_ambient_pressure_compensation(float pressure_in_hpa); @@ -36,9 +37,6 @@ class STCC4Component final : public PollingComponent, public sensirion_common::S bool write_rht_compensation_(uint16_t temperature_in_ticks, uint16_t humidity_in_ticks); bool write_ambient_pressure_compensation_(uint16_t pressure_in_pa_2); - sensor::Sensor *co2_sensor_{nullptr}; - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; sensor::Sensor *temperature_source_{nullptr}; sensor::Sensor *humidity_source_{nullptr}; sensor::Sensor *ambient_pressure_source_{nullptr}; From e7a7aa150dab1f645520bf7807f477a9e8400384 Mon Sep 17 00:00:00 2001 From: Gunni Date: Tue, 13 Oct 2026 01:36:57 +0000 Subject: [PATCH 253/268] [yaml_util] Raise a clear error for a non-mapping `defaults:` in a package (#19425) Co-authored-by: Claude Opus 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/yaml_util.py | 15 ++++++- tests/unit_tests/test_substitutions.py | 58 ++++++++++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 23 ++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index f02819a3ed..b5e2ec17e8 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -191,10 +191,23 @@ def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: If the value is already tagged, the new context vars are merged with existing ones, with new vars taking precedence. Returns the value tagged with ConfigContext. Returns the original value if value is not a list/string/dict. + + Raises EsphomeError if a `defaults:` key is present but is not a mapping. """ if isinstance(value, dict) and CONF_DEFAULTS in value: + defaults = value.pop(CONF_DEFAULTS) + if defaults is None: + raise EsphomeError( + f"'{CONF_DEFAULTS}' is empty; " + f"remove the key or give it at least one value" + ) + if not isinstance(defaults, dict): + raise EsphomeError( + f"'{CONF_DEFAULTS}' must be a key to value mapping, " + f"got {type(defaults)}" + ) context_vars = { - **value.pop(CONF_DEFAULTS), + **defaults, **(context_vars or {}), } diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 198b002d43..b0d3656101 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -988,6 +988,64 @@ def test_resolve_include_error_shows_expanded_from_when_substituted( assert "expanded from '${device}.yaml'" in msg +def test_resolve_include_empty_defaults_names_file(tmp_path: Path) -> None: + """An empty `defaults:` in an included file surfaces as cv.Invalid naming that file. + + add_context() raises EsphomeError, so resolve_include's existing handler can + wrap it with the filename and document path. A bare TypeError would escape + that handler and reach the user as an unhandled traceback. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(_path: Path) -> dict[str, Any]: + return {"defaults": None, "ota": [{"platform": "esphome"}]} + + include = yaml_util.IncludeFile(parent, "_network/ota.yaml", loader) + + with pytest.raises(cv.Invalid) as exc_info: + substitutions.resolve_include(include, [], substitutions.ContextVars()) + + msg = str(exc_info.value) + assert "_network/ota.yaml" in msg + assert "'defaults' is empty" in msg + + +def test_do_packages_pass_nested_empty_defaults_names_file(tmp_path: Path) -> None: + """An empty `defaults:` two package levels deep still names the offending file. + + Mirrors a real layout: the top-level config includes a network package whose + own `packages:` block includes the file with the empty `defaults:`. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def inner_loader(_path: Path) -> dict[str, Any]: + return {"defaults": None, "ota": [{"platform": "esphome"}]} + + def outer_loader(_path: Path) -> dict[str, Any]: + return { + "packages": { + "otas": yaml_util.IncludeFile(parent, "_network/ota.yaml", inner_loader) + } + } + + config = OrderedDict( + { + "packages": { + "wifis": yaml_util.IncludeFile(parent, "network.yaml", outer_loader) + } + } + ) + + with pytest.raises(cv.Invalid) as exc_info: + do_packages_pass(config) + + msg = str(exc_info.value) + assert "_network/ota.yaml" in msg + assert "'defaults' is empty" in msg + + def test_resolve_include_error_no_expanded_from_for_literal_filename( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index c30b03467f..c3276823ec 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -569,6 +569,29 @@ def test_config_context_defaults_explicit_vars_override() -> None: assert "defaults" not in tagged +def test_config_context_defaults_empty_raises() -> None: + """Test that an empty defaults: key raises EsphomeError, not a bare TypeError. + + A ``defaults:`` block with an empty (or comments-only) body parses as None. + Unpacking it used to fail with ``TypeError: 'NoneType' object is not a + mapping``, which escapes the EsphomeError handler in resolve_include and + reaches the user as an unhandled traceback naming no file. + """ + data = {"defaults": None, "key": "value"} + + with pytest.raises(EsphomeError, match="'defaults' is empty"): + yaml_util.add_context(data, None) + + +@pytest.mark.parametrize("defaults", ["oops", ["a", "b"], 42]) +def test_config_context_defaults_non_mapping_raises(defaults) -> None: + """Test that a non-mapping defaults: value raises EsphomeError naming the type.""" + data = {"defaults": defaults, "key": "value"} + + with pytest.raises(EsphomeError, match="'defaults' must be a key to value mapping"): + yaml_util.add_context(data, None) + + def test_represent_extend() -> None: """Test that Extend objects are dumped as plain !extend scalars.""" assert yaml_util.dump({"key": Extend("my_id")}) == "key: !extend 'my_id'\n" From 88fdf822cdbc47d127ed686029dc038e17ae0ac3 Mon Sep 17 00:00:00 2001 From: jcianci12 Date: Tue, 13 Oct 2026 11:41:30 +1000 Subject: [PATCH 254/268] [runtime_image] Speed up RGB565 JPEG decoding (#19454) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../runtime_image/image_decoder.cpp | 3 + .../components/runtime_image/image_decoder.h | 4 + .../components/runtime_image/jpeg_decoder.cpp | 90 +++++++++++++++++-- .../components/runtime_image/jpeg_decoder.h | 12 ++- .../components/runtime_image/runtime_image.h | 2 + .../runtime_image/test_decoder_reuse.cpp | 84 +++++++++++++++++ 6 files changed, 188 insertions(+), 7 deletions(-) diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index 7056ce35aa..55e25d76b7 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -29,4 +29,7 @@ void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) { } } +uint8_t *ImageDecoder::image_buffer_() const { return this->image_->buffer_; } +bool ImageDecoder::image_is_big_endian_() const { return this->image_->is_big_endian_; } + } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 2a8b393888..8e0399ac35 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -127,6 +127,10 @@ class ImageDecoder { } protected: + /// The image's pixel buffer, for decoders that write their output straight into it + uint8_t *image_buffer_() const; + bool image_is_big_endian_() const; + double x_scale_ = 1.0; double y_scale_ = 1.0; RuntimeImage *image_; diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index 74ced63a4e..d4783701c4 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -2,6 +2,7 @@ #ifdef USE_RUNTIME_IMAGE_JPEG #include "esphome/components/display/display_buffer.h" +#include "esphome/components/image/image.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -15,6 +16,67 @@ ESPHOME_LOG_TAG(TAG, "image_decoder.jpeg"); namespace esphome::runtime_image { +bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) { + if (this->x_boundaries_.capacity() < static_cast(width + 1) && !this->x_boundaries_.try_init(width + 1)) { + return false; + } + if (this->y_boundaries_.capacity() < static_cast(height + 1) && !this->y_boundaries_.try_init(height + 1)) { + return false; + } + + this->source_width_ = width; + this->source_height_ = height; + this->x_boundaries_.clear(); + this->y_boundaries_.clear(); + const uint32_t target_width = this->image_->get_buffer_width(); + const uint32_t target_height = this->image_->get_buffer_height(); + // Boundary n is the first target pixel source pixel n covers, rounded up. When shrinking, the source + // pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6; + // the generic draw() path rounds down and keeps the odd ones instead + for (uint32_t x = 0; x <= static_cast(width); x++) { + this->x_boundaries_.push_back(static_cast((x * target_width + width - 1) / width)); + } + for (uint32_t y = 0; y <= static_cast(height); y++) { + this->y_boundaries_.push_back(static_cast((y * target_height + height - 1) / height)); + } + return true; +} + +void JpegDecoder::draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels) { + if (pixels == nullptr) { + return; + } + uint8_t *const buffer = this->image_buffer_(); + const uint32_t buffer_width = this->image_->get_buffer_width(); + // JPEGDEC gives little-endian words; a big-endian image stores the high byte first + const uint8_t first = this->image_is_big_endian_() ? 1 : 0; + for (int source_y = 0; source_y < h; source_y++) { + const int absolute_y = y + source_y; + if (absolute_y < 0 || absolute_y >= this->source_height_) { + continue; + } + const uint16_t y1 = this->y_boundaries_[absolute_y]; + const uint16_t y2 = this->y_boundaries_[absolute_y + 1]; + for (int source_x = 0; source_x < w; source_x++) { + const int absolute_x = x + source_x; + if (absolute_x < 0 || absolute_x >= this->source_width_) { + continue; + } + const uint16_t x1 = this->x_boundaries_[absolute_x]; + const uint16_t x2 = this->x_boundaries_[absolute_x + 1]; + const uint8_t *pixel = pixels + (source_y * w + source_x) * 2; + for (uint16_t destination_y = y1; destination_y < y2; destination_y++) { + uint8_t *destination = buffer + (destination_y * buffer_width + x1) * 2; + for (uint16_t destination_x = x1; destination_x < x2; destination_x++) { + destination[0] = pixel[first]; + destination[1] = pixel[1 - first]; + destination += 2; + } + } + } + } +} + /** * @brief Callback method that will be called by the JPEGDEC engine when a chunk * of the image is decoded. @@ -22,7 +84,12 @@ namespace esphome::runtime_image { * @param jpeg The JPEGDRAW object, including the context data. */ static int draw_callback(JPEGDRAW *jpeg) { - ImageDecoder *decoder = (ImageDecoder *) jpeg->pUser; + JpegDecoder *decoder = (JpegDecoder *) jpeg->pUser; + + if (decoder == nullptr) { + ESP_LOGE(TAG, "Decoder pointer is null!"); + return 0; + } // Some very big images take too long to decode, so feed the watchdog on each callback // to avoid crashing if the executing task has a watchdog enabled. @@ -33,6 +100,11 @@ static int draw_callback(JPEGDRAW *jpeg) { #ifdef USE_ESP_IDF } #endif + if (decoder->uses_direct_rgb565()) { + decoder->draw_rgb565(jpeg->x, jpeg->y, jpeg->iWidth, jpeg->iHeight, + reinterpret_cast(jpeg->pPixels)); + return 1; + } size_t position = 0; size_t height = static_cast(jpeg->iHeight); size_t width = static_cast(jpeg->iWidth); @@ -42,10 +114,6 @@ static int draw_callback(JPEGDRAW *jpeg) { auto ba = decode_value(jpeg->pPixels[position++]); Color color(rg[1], rg[0], ba[1], ba[0]); - if (!decoder) { - ESP_LOGE(TAG, "Decoder pointer is null!"); - return 0; - } decoder->draw(jpeg->x + x, jpeg->y + y, 1, 1, color); } } @@ -78,10 +146,20 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp()); this->jpeg_.setUserPointer(this); - this->jpeg_.setPixelType(RGB8888); + this->direct_rgb565_ = this->image_->get_type() == image::IMAGE_TYPE_RGB565 && !this->image_->has_transparency(); + if (this->direct_rgb565_) { + // JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build + // returns little-endian words for it), so the byte order is applied in draw_rgb565 instead + this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN); + } else { + this->jpeg_.setPixelType(RGB8888); + } if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) { return DECODE_ERROR_OUT_OF_MEMORY; } + if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) { + return DECODE_ERROR_OUT_OF_MEMORY; + } if (!this->jpeg_.decode(0, 0, 0)) { auto error = this->jpeg_.getLastError(); ESP_LOGE(TAG, "Error while decoding: %d", error); diff --git a/esphome/components/runtime_image/jpeg_decoder.h b/esphome/components/runtime_image/jpeg_decoder.h index 67c9b77f4d..f72f69422d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.h +++ b/esphome/components/runtime_image/jpeg_decoder.h @@ -1,8 +1,8 @@ #pragma once - #include "image_decoder.h" #include "runtime_image.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_RUNTIME_IMAGE_JPEG #include @@ -23,8 +23,18 @@ class JpegDecoder : public ImageDecoder { int HOT decode(uint8_t *buffer, size_t size) override; + bool uses_direct_rgb565() const { return this->direct_rgb565_; } + void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels); + protected: + bool prepare_rgb565_scaling_(int width, int height); + JPEGDEC jpeg_{}; + FixedVector x_boundaries_{}; + FixedVector y_boundaries_{}; + int source_width_{0}; + int source_height_{0}; + bool direct_rgb565_{false}; }; } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 55d5c0ae86..33bad52acf 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -234,6 +234,8 @@ class RuntimeImage : public image::Image { bool is_big_endian_{false}; /** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */ bool external_buffer_{false}; + + friend class ImageDecoder; }; } // namespace esphome::runtime_image diff --git a/tests/components/runtime_image/test_decoder_reuse.cpp b/tests/components/runtime_image/test_decoder_reuse.cpp index e62e2a98d9..9f7190980b 100644 --- a/tests/components/runtime_image/test_decoder_reuse.cpp +++ b/tests/components/runtime_image/test_decoder_reuse.cpp @@ -101,6 +101,9 @@ class TestableRuntimeImage : public RuntimeImage { explicit TestableRuntimeImage(ImageFormat format, image::Transparency transparency = image::TRANSPARENCY_OPAQUE) : RuntimeImage(format, image::IMAGE_TYPE_RGB, transparency, nullptr, false, 0, 0) {} + TestableRuntimeImage(ImageFormat format, image::ImageType type, bool is_big_endian, int fixed_width, int fixed_height) + : RuntimeImage(format, type, image::TRANSPARENCY_OPAQUE, nullptr, is_big_endian, fixed_width, fixed_height) {} + ImageDecoder *decoder() { return this->decoder_.get(); } }; @@ -373,6 +376,87 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) { EXPECT_EQ(img.decoder(), first); EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels"; } + +// JPEGDEC's colour conversion differs by one step between its SIMD and scalar builds, so each +// channel is checked within one step; the byte order is still exact +static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *order, size_t pixel) { + EXPECT_NEAR(actual >> 11, expected >> 11, 1) << order << " pixel " << pixel << " red"; + EXPECT_NEAR((actual >> 5) & 0x3F, (expected >> 5) & 0x3F, 1) << order << " pixel " << pixel << " green"; + EXPECT_NEAR(actual & 0x1F, expected & 0x1F, 1) << order << " pixel " << pixel << " blue"; +} + +TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) { + // Halving the 8x8 gradient keeps its even rows and columns. The words are JPEGDEC's output for those + // pixels, red in the top five bits: a decode of the fixture with PIL gives (51, 7, 30) for source + // pixel (2, 0), which is word 0x3803 here. + static const uint16_t EXPECTED[4 * 4] = { + 0x0000, 0x3803, 0x8008, 0xB80B, 0x0203, 0x3A07, 0x81EC, 0xBA0F, + 0x03E7, 0x3C0B, 0x83F0, 0xBC13, 0x05EC, 0x3E0F, 0x85F4, 0xBE17, + }; + TestableRuntimeImage rgb565_little_endian(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4); + TestableRuntimeImage rgb565_big_endian(JPEG, image::IMAGE_TYPE_RGB565, true, 4, 4); + + ASSERT_TRUE(decode_all(rgb565_little_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_TRUE(decode_all(rgb565_big_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(rgb565_little_endian.get_width(), 4); + ASSERT_EQ(rgb565_little_endian.get_height(), 4); + ASSERT_EQ(rgb565_big_endian.get_width(), 4); + ASSERT_EQ(rgb565_big_endian.get_height(), 4); + + const uint8_t *little = rgb565_little_endian.get_data_start(); + const uint8_t *big = rgb565_big_endian.get_data_start(); + for (size_t i = 0; i < 4 * 4; i++) { + expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), EXPECTED[i], "little-endian", i); + expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], EXPECTED[i], "big-endian", i); + } +} + +// Target pixel index each source pixel paints when `source` pixels map onto `target`, the way +// prepare_rgb565_scaling_ rounds: source n covers targets ceil(n * target / source) up to the next start +static uint16_t scaled_boundary(int n, int source, int target) { return (n * target + source - 1) / source; } + +// The RGB565 word at (x, y) of a decoded image, in its own byte order +static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) { + const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2; + return p[0] | (p[1] << 8); +} + +// Every scaled direct decode must equal the unscaled one sampled through the boundary table, +// for shrinking, growing and a ratio that is not a whole number +TEST(RuntimeImageDecoder, JpegDirectRgb565ScalesBySourcePixelSpans) { + TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8); + ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + + for (int size : {4, 6, 16}) { + TestableRuntimeImage scaled(JPEG, image::IMAGE_TYPE_RGB565, false, size, size); + ASSERT_TRUE(decode_all(scaled, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(scaled.get_width(), size); + ASSERT_EQ(scaled.get_height(), size); + for (int n = 0; n < 8; n++) { + for (int m = 0; m < 8; m++) { + for (int y = scaled_boundary(m, 8, size); y < scaled_boundary(m + 1, 8, size); y++) { + for (int x = scaled_boundary(n, 8, size); x < scaled_boundary(n + 1, 8, size); x++) { + EXPECT_EQ(rgb565_word(scaled, x, y), rgb565_word(full, n, m)) + << size << "x" << size << " pixel " << x << "," << y; + } + } + } + } + } +} + +// A second decode into the same image reuses the boundary tables and gives the same pixels +TEST(RuntimeImageDecoder, JpegDirectRgb565RepeatsWithReusedTables) { + TestableRuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, false, 6, 6); + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + std::vector first_pixels = pixel_bytes(img); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + EXPECT_EQ(img.decoder(), first); + EXPECT_EQ(pixel_bytes(img), first_pixels); +} #endif // USE_RUNTIME_IMAGE_JPEG TEST(RuntimeImageDecoder, QoiDecoderStaysWarmAcrossDecodes) { From a035c17e0482bde73510d321334d03fcc57b15e8 Mon Sep 17 00:00:00 2001 From: t2bottom <99073885+t2bottom@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:42:24 +0200 Subject: [PATCH 255/268] [gt911] Fix the interrupt pin initialisation after reset (#15021) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../gt911/touchscreen/gt911_touchscreen.cpp | 23 +++++++++++-------- .../gt911/touchscreen/gt911_touchscreen.h | 13 ++--------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 07dd325a2c..388b8165fd 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -24,9 +24,13 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned return; \ } +// Reset sequence (Goodix GT911 datasheet): INT held low while RST is released selects address 0x5D, then +// T3 + T4 pass before INT is handed back to the chip as its interrupt output +static constexpr uint32_t RESET_LOW_MS = 10; +static constexpr uint32_t RESET_SETTLE_MS = 5 + 50 + 1; + void GT911Touchscreen::setup() { if (this->reset_pin_ != nullptr) { - // temporarily set the interrupt pin to output to control address selection this->reset_pin_->setup(); this->reset_pin_->digital_write(false); if (this->interrupt_pin_ != nullptr) { @@ -34,20 +38,19 @@ void GT911Touchscreen::setup() { this->interrupt_pin_->pin_mode(gpio::FLAG_OUTPUT); this->interrupt_pin_->digital_write(false); } - delay(2); - this->reset_pin_->digital_write(true); - // wait at least T3+T4 ms as per the datasheet - this->set_timeout(5 + 50 + 1, [this] { this->setup_internal_(); }); + this->set_timeout(RESET_LOW_MS, [this] { + this->reset_pin_->digital_write(true); + this->set_timeout(RESET_SETTLE_MS, [this] { this->setup_internal_(); }); + }); return; } this->setup_internal_(); } void GT911Touchscreen::setup_internal_() { - if (this->interrupt_pin_ != nullptr) { - if (this->interrupt_pin_->is_internal()) - this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT); - } + // Expander pins must leave output mode too, or the chip cannot drive its interrupt line + if (this->interrupt_pin_ != nullptr) + this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT); uint8_t data[4]; i2c::ErrorCode err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); @@ -72,7 +75,7 @@ void GT911Touchscreen::setup_internal_() { ESP_LOGD(TAG, "Interrupt pin: hardware interrupt, active %s", active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW")); } else { - // IO expander pin: leave as output for configuration only. + // IO expander pin: no hardware interrupt, the touch state is polled. ESP_LOGD(TAG, "Interrupt pin: IO expander polling mode, active %s", active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW")); } diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index 465df528e5..13fb6b05da 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -14,17 +14,8 @@ class GT911ButtonListener { class GT911Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: - /// @brief Initialize the GT911 touchscreen. - /// - /// If @ref reset_pin_ is set, the touchscreen will be hardware reset, - /// and the rest of the setup will be scheduled to run 50ms later using @ref set_timeout() - /// to allow the device to stabilize after reset. - /// - /// If @ref interrupt_pin_ is set, it will be temporarily configured during reset - /// to control I2C address selection. - /// - /// After the timeout, or immediately if no reset is performed, @ref setup_internal_() - /// is called to complete the initialization. + /// With a reset pin the chip is reset with the interrupt pin held low, which selects address 0x5D, and the + /// rest of the setup runs from timeouts once the chip is ready. Without one setup_internal_() runs at once. void setup() override; void dump_config() override; bool can_proceed() override { return this->setup_done_; } From 36900e36f91ecd271cb6f1afee820f161aec635c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:45:09 -1000 Subject: [PATCH 256/268] [esp32_power_management] Build the flash power-down path in a standalone test variant (#20612) --- tests/components/esp32_power_management/common.yaml | 2 +- .../test-flash-power-down.esp32-idf.yaml | 8 ++++++++ .../components/esp32_power_management/test.esp32-ard.yaml | 1 + .../esp32_power_management/test.esp32-h2-idf.yaml | 1 + .../components/esp32_power_management/test.esp32-idf.yaml | 1 + 5 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml diff --git a/tests/components/esp32_power_management/common.yaml b/tests/components/esp32_power_management/common.yaml index 74d93e6577..78d64c634a 100644 --- a/tests/components/esp32_power_management/common.yaml +++ b/tests/components/esp32_power_management/common.yaml @@ -5,7 +5,7 @@ power_management: min_frequency: ${esp32_power_management_min_frequency} enable_light_sleep: true idle_time_before_sleep: 3 - power_down_flash: true + power_down_flash: ${esp32_power_management_power_down_flash} power_down_peripherals: ${esp32_power_management_power_down_peripherals} profiling: true trace: true diff --git a/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml b/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml new file mode 100644 index 0000000000..364ad5329d --- /dev/null +++ b/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "true" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-ard.yaml b/tests/components/esp32_power_management/test.esp32-ard.yaml index 2b25b2d23f..513617a53b 100644 --- a/tests/components/esp32_power_management/test.esp32-ard.yaml +++ b/tests/components/esp32_power_management/test.esp32-ard.yaml @@ -2,6 +2,7 @@ substitutions: esp32_power_management_max_frequency: 160MHZ esp32_power_management_min_frequency: 40MHZ esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "false" packages: esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-h2-idf.yaml b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml index c571a2be0c..7bf893aeff 100644 --- a/tests/components/esp32_power_management/test.esp32-h2-idf.yaml +++ b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml @@ -2,6 +2,7 @@ substitutions: esp32_power_management_max_frequency: 96MHZ esp32_power_management_min_frequency: 32MHZ esp32_power_management_power_down_peripherals: "true" + esp32_power_management_power_down_flash: "false" packages: esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-idf.yaml b/tests/components/esp32_power_management/test.esp32-idf.yaml index 2b25b2d23f..513617a53b 100644 --- a/tests/components/esp32_power_management/test.esp32-idf.yaml +++ b/tests/components/esp32_power_management/test.esp32-idf.yaml @@ -2,6 +2,7 @@ substitutions: esp32_power_management_max_frequency: 160MHZ esp32_power_management_min_frequency: 40MHZ esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "false" packages: esp32_power_management: !include common.yaml From fb14db267cbca1fe96a1a5ed4f4e8eac8c8c85f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:45:52 -1000 Subject: [PATCH 257/268] [const] Move CONF_SERIAL_NUMBER to the shared component constants (#20611) --- esphome/components/const/__init__.py | 1 + esphome/components/hoermann_hcp/text_sensor/__init__.py | 2 +- esphome/components/sun_gtil2/text_sensor.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 78778553aa..49f55ab47f 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -59,6 +59,7 @@ CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" CONF_SCAN_PARAMETERS = "scan_parameters" +CONF_SERIAL_NUMBER = "serial_number" CONF_SHA256 = "sha256" CONF_SLOT = "slot" CONF_STATE_SAVE_INTERVAL = "state_save_interval" diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py index da460905c0..7aedf59924 100644 --- a/esphome/components/hoermann_hcp/text_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.const import CONF_SERIAL_NUMBER import esphome.config_validation as cv from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP from esphome.types import ConfigType @@ -9,7 +10,6 @@ from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns DEPENDENCIES = ["hoermann_hcp"] CONF_DOOR_STATE = "door_state" -CONF_SERIAL_NUMBER = "serial_number" HoermannHcpDoorStateTextSensor = hoermann_hcp_ns.class_( "HoermannHcpDoorStateTextSensor", text_sensor.TextSensor, cg.Component diff --git a/esphome/components/sun_gtil2/text_sensor.py b/esphome/components/sun_gtil2/text_sensor.py index c0a60cacbf..753401e6aa 100644 --- a/esphome/components/sun_gtil2/text_sensor.py +++ b/esphome/components/sun_gtil2/text_sensor.py @@ -1,13 +1,12 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.const import CONF_SERIAL_NUMBER import esphome.config_validation as cv from esphome.const import CONF_STATE from esphome.types import ConfigType from . import CONF_SUN_GTIL2_ID, SunGTIL2Component -CONF_SERIAL_NUMBER = "serial_number" - CONFIG_SCHEMA = cv.All( cv.Schema( { From 5a39097b3a73052d09fe06d8fb5770605961e22e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:46:41 -1000 Subject: [PATCH 258/268] [light] Keep one copy of the color mode lookup table in flash (#20394) --- esphome/components/light/color_mode.h | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/components/light/color_mode.h b/esphome/components/light/color_mode.h index 0750ae250d..fbce5232ff 100644 --- a/esphome/components/light/color_mode.h +++ b/esphome/components/light/color_mode.h @@ -2,6 +2,7 @@ #include #include "esphome/core/finite_set_mask.h" +#include "esphome/core/hal.h" namespace esphome::light { @@ -108,8 +109,9 @@ constexpr ColorModeHelper operator|(ColorModeHelper lhs, ColorMode rhs) { using color_mode_bitmask_t = uint16_t; // Lookup table for ColorMode bit mapping -// This array defines the canonical order of color modes (bit 0-9) -constexpr ColorMode COLOR_MODE_LOOKUP[] = { +// This array defines the canonical order of color modes (bit 0-9). One shared copy in flash; read it at run time +// through color_mode_at(). +inline constexpr ColorMode COLOR_MODE_LOOKUP[] PROGMEM = { ColorMode::UNKNOWN, // bit 0 ColorMode::ON_OFF, // bit 1 ColorMode::BRIGHTNESS, // bit 2 @@ -122,6 +124,12 @@ constexpr ColorMode COLOR_MODE_LOOKUP[] = { ColorMode::RGB_COLD_WARM_WHITE, // bit 9 }; +constexpr ColorMode color_mode_at(unsigned bit) { + if (__builtin_is_constant_evaluated()) + return COLOR_MODE_LOOKUP[bit]; + return static_cast(progmem_read_byte(reinterpret_cast(&COLOR_MODE_LOOKUP[bit]))); +} + /// Bit mapping policy for ColorMode /// Uses lookup table for non-contiguous enum values struct ColorModeBitPolicy { @@ -129,17 +137,15 @@ struct ColorModeBitPolicy { static constexpr int MAX_BITS = sizeof(COLOR_MODE_LOOKUP) / sizeof(COLOR_MODE_LOOKUP[0]); static constexpr unsigned to_bit(ColorMode mode) { - // Linear search through lookup table - // Compiler optimizes this to efficient code since array is constexpr for (int i = 0; i < MAX_BITS; ++i) { - if (COLOR_MODE_LOOKUP[i] == mode) + if (color_mode_at(i) == mode) return i; } return 0; } static constexpr ColorMode from_bit(unsigned bit) { - return (bit < MAX_BITS) ? COLOR_MODE_LOOKUP[bit] : ColorMode::UNKNOWN; + return (bit < MAX_BITS) ? color_mode_at(bit) : ColorMode::UNKNOWN; } }; @@ -157,7 +163,7 @@ constexpr uint16_t compute_capability_bitmask(ColorCapability capability) { // Check each ColorMode to see if it has this capability constexpr int color_mode_count = sizeof(COLOR_MODE_LOOKUP) / sizeof(COLOR_MODE_LOOKUP[0]); for (int bit = 0; bit < color_mode_count; ++bit) { - uint8_t mode_val = static_cast(COLOR_MODE_LOOKUP[bit]); + uint8_t mode_val = static_cast(color_mode_at(bit)); if ((mode_val & cap_bit) != 0) { mask |= (1 << bit); } From 2001f60954ea807ddf29b21a41bf0a2105d61f1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:48:05 -1000 Subject: [PATCH 259/268] [core] Keep string constants in apply conditions in flash on ESP8266 (#20107) --- esphome/automation.py | 27 +++++++++++++++++++++------ esphome/core/string_ref.h | 19 +++++++++++++++++++ tests/unit_tests/test_automation.py | 22 +++++++++++++++++----- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 393a79422b..4a2c10cb52 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -248,6 +248,23 @@ def flash_string(config: ConfigType, value: str) -> str: return str(cg.progmem_string(value)) +def flash_compare(config: ConfigType, value: str) -> str: + """Default renderer for ``std::string`` constants in conditions; compares in flash on ESP8266.""" + if CORE.is_esp8266: + return f"::esphome::ProgmemStringRef{{PSTR({cg.safe_exp(value)}), {len(value.encode('utf-8'))}}}" + return str(cg.safe_exp(value)) + + +def _default_std_string( + members: list[tuple[str, Any, Any]], renderer: Callable[[ConfigType, str], str] +) -> list[tuple[str, Any, Any]]: + """Give ``cg.std_string`` members without a renderer the given default.""" + return [ + (key, t, fn or (renderer if t is cg.std_string else None)) + for key, t, fn in members + ] + + def literal_with_length(config: ConfigType, value: str) -> str: """Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length. @@ -491,10 +508,7 @@ def apply_action_builder( statements_spec = [ ( c.target, - [ - (key, t, fn or (flash_string if t is cg.std_string else None)) - for key, t, fn in c.members - ], + _default_std_string(c.members, flash_string), ) for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) ] @@ -563,11 +577,12 @@ def register_apply_condition( ``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an ``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares against config values, all of which must be present. Write ``== false`` to negate. - String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member. + A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=`` + against a ``std::string`` or ``StringRef`` member; other string types are plain literals. Generates one static predicate and an ``ApplyCondition`` templated on it. """ call = check if isinstance(check, ApplyCall) else ApplyCall(check) - members = call.members + members = _default_std_string(call.members, flash_compare) _check_key_in_schema(name, schema, id_key) for conf_key, _, _ in members: _check_key_in_schema(name, schema, conf_key) diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 1427541229..e9e31411e4 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -188,6 +188,25 @@ inline bool operator==(const __FlashStringHelper *lhs, const StringRef &rhs) { r inline bool operator!=(const StringRef &lhs, const __FlashStringHelper *rhs) { return !(lhs == rhs); } inline bool operator!=(const __FlashStringHelper *lhs, const StringRef &rhs) { return !(rhs == lhs); } + +/// Codegen use only: a flash string literal with its byte length, compared without a runtime strlen. +struct ProgmemStringRef { + PGM_P str; + size_t len; +}; + +inline bool operator==(const std::string &lhs, ProgmemStringRef rhs) { + return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.data(), rhs.str, rhs.len) == 0); +} + +inline bool operator!=(const std::string &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } + +inline bool operator==(const StringRef &lhs, ProgmemStringRef rhs) { + // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes + return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.c_str(), rhs.str, rhs.len) == 0); +} + +inline bool operator!=(const StringRef &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } #endif // USE_ESP8266 inline bool operator<(const StringRef &lhs, const StringRef &rhs) { diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index f2d4628044..fe0b1b02ed 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1293,13 +1293,25 @@ async def test_apply_condition_compares_config_value( @pytest.mark.asyncio -@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) -async def test_apply_condition_string_constant_is_a_plain_literal( - registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +@pytest.mark.parametrize( + ("platform", "value", "expected"), + [ + ("esp32", "two", '"two"'), + ("esp8266", "two", '::esphome::ProgmemStringRef{PSTR("two"), 3}'), + # The length is in bytes, not characters + ("esp8266", "é", '::esphome::ProgmemStringRef{PSTR("\\303\\251"), 2}'), + ], +) +async def test_apply_condition_string_constant( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + platform: str, + value: str, + expected: str, ) -> None: check = ApplyCall("state == {}", (("state", cg.std_string),)) - await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) - assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_definition(mock_cg) + await _run_apply_condition(registries, check, {"state": value}, platform=platform) + assert f"return ::{PARENT_OBJ}->state == {expected};" in _apply_definition(mock_cg) @pytest.mark.asyncio From 02287fdf7b0de1d45a908884867fb8d813d44a4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:48:27 -1000 Subject: [PATCH 260/268] [sensor] Share filter_out and throttle_with_priority value tables in flash (#20078) --- esphome/components/sensor/__init__.py | 32 ++++++++---- esphome/components/sensor/filter.cpp | 21 ++++---- esphome/components/sensor/filter.h | 49 ++++++------------ esphome/core/automation.h | 5 +- .../component_tests/sensor/test_value_list.py | 43 ++++++++++++++++ .../sensor/test_value_list.yaml | 51 +++++++++++++++++++ 6 files changed, 146 insertions(+), 55 deletions(-) create mode 100644 tests/component_tests/sensor/test_value_list.py create mode 100644 tests/component_tests/sensor/test_value_list.yaml diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 8f345e3e66..79c6785be2 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -113,7 +113,7 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( SubEntities, @@ -421,16 +421,30 @@ async def multiply_filter_to_code(config, filter_id): return cg.new_Pvariable(filter_id, template_) +TemplatableFloat = cg.esphome_ns.class_("TemplatableFn").template(cg.float_) + + +async def _value_list_table(values: list[Any]) -> MockObj: + """PROGMEM value table, ended by an empty entry so filters store no count.""" + fns = [await cg.templatable(x, [], cg.float_) for x in values] + # Identical lambdas (e.g. a YAML anchor) may keep static state, so only constants share. + return cg.shared_progmem_array( + "sensor_value_list", + TemplatableFloat, + [*fns, TemplatableFloat()], + share=not any(isinstance(v, Lambda) for v in values), + ) + + @FILTER_REGISTRY.register( "filter_out", FilterOutValueFilter, cv.Any(cv.templatable(cv.float_), [cv.templatable(cv.float_)]), ) -async def filter_out_filter_to_code(config, filter_id): +async def filter_out_filter_to_code(config: Any, filter_id: ID) -> MockObj: if not isinstance(config, list): config = [config] - template_ = [await cg.templatable(x, [], cg.float_) for x in config] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_) + return cg.new_Pvariable(filter_id, await _value_list_table(config)) QUANTILE_SCHEMA = cv.All( @@ -704,7 +718,9 @@ def _is_nan_only(values: Any) -> bool: ThrottleWithPriorityFilter, THROTTLE_WITH_PRIORITY_SCHEMA, ) -async def throttle_with_priority_filter_to_code(config, filter_id): +async def throttle_with_priority_filter_to_code( + config: ConfigType, filter_id: ID +) -> MockObj: values = config[CONF_VALUE] if not isinstance(values, list): values = [values] @@ -716,10 +732,8 @@ async def throttle_with_priority_filter_to_code(config, filter_id): filter_id = filter_id.copy() filter_id.type = ThrottleWithPriorityNanFilter return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) - template_ = [await cg.templatable(x, [], cg.float_) for x in values] - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ - ) + table = await _value_list_table(values) + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], table) HEARTBEAT_SCHEMA = cv.Schema( diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index ed48ddebdb..9b027c4e11 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -226,14 +226,14 @@ MultiplyFilter::MultiplyFilter(TemplatableFn multiplier) : multiplier_(mu optional MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); } -// ValueListFilter helper (non-template, shared by all ValueListFilter instantiations) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count) { - int8_t accuracy = parent->get_accuracy_decimals(); +// ValueListFilter +bool ValueListFilter::value_matches_any_(float sensor_value) { + int8_t accuracy = this->parent_->get_accuracy_decimals(); float accuracy_mult = pow10_int(accuracy); float rounded_sensor = roundf(accuracy_mult * sensor_value); - for (size_t i = 0; i < count; i++) { - float fv = values[i].value(); + for (const TemplatableFn *v = this->values_; v->has_value(); v++) { + float fv = v->value(); // Handle NaN comparison if (std::isnan(fv)) { @@ -261,13 +261,12 @@ optional ThrottleFilter::new_value(float value) { return {}; } -// ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, - size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) { +// ThrottleWithPriorityFilter +optional ThrottleWithPriorityFilter::new_value(float value) { const uint32_t now = App.get_loop_component_start_time(); - if (last_input == 0 || now - last_input >= min_time_between_inputs || - value_list_matches_any(parent, value, values, count)) { - last_input = now; + if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || + this->value_matches_any_(value)) { + this->last_input_ = now; return value; } return {}; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 9b5ef49308..5ff99881de 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -330,36 +330,27 @@ class MultiplyFilter : public Filter { TemplatableFn multiplier_; }; -/// Non-template helper for value matching (implementation in filter.cpp) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count); +// Value tables are read in place from flash, which ESP8266 only allows for whole words. +static_assert(sizeof(TemplatableFn) == sizeof(void *), "TemplatableFn must stay a single pointer"); -/** Base class for filters that compare sensor values against a fixed list of configured values. - * - * Templated on N (the number of values) so the list is stored inline in a std::array, - * avoiding heap allocation and the overhead of FixedVector. - * - * @tparam N Number of values in the filter list, set by code generation to match - * the exact number of values configured in YAML. - */ -template class ValueListFilter : public Filter { +/// Base class for filters that compare sensor values against a fixed list of configured values. +class ValueListFilter : public Filter { protected: - explicit ValueListFilter(std::initializer_list> values) { - init_array_from(this->values_, values); - } + explicit ValueListFilter(const TemplatableFn *values) : values_(values) {} /// Check if sensor value matches any configured value (with accuracy rounding) - bool value_matches_any_(float sensor_value) { - return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N); - } + bool value_matches_any_(float sensor_value); - std::array, N> values_{}; + // Shared PROGMEM table ended by an empty entry, so no count is stored; entries are function + // pointers, so aligned 32-bit loads from flash are safe on ESP8266. + const TemplatableFn *values_; }; /// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`. -template class FilterOutValueFilter : public ValueListFilter { +class FilterOutValueFilter : public ValueListFilter { public: - explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) - : ValueListFilter(values_to_filter_out) {} + explicit FilterOutValueFilter(const TemplatableFn *values_to_filter_out) + : ValueListFilter(values_to_filter_out) {} optional new_value(float value) override { if (this->value_matches_any_(value)) @@ -379,21 +370,13 @@ class ThrottleFilter : public Filter { uint32_t min_time_between_inputs_; }; -/// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, - size_t count, uint32_t &last_input, uint32_t min_time_between_inputs); - /// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`. -template class ThrottleWithPriorityFilter : public ValueListFilter { +class ThrottleWithPriorityFilter : public ValueListFilter { public: - explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, - std::initializer_list> prioritized_values) - : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} + ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, const TemplatableFn *prioritized_values) + : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} - optional new_value(float value) override { - return throttle_with_priority_new_value(this->parent_, value, this->values_.data(), N, this->last_input_, - this->min_time_between_inputs_); - } + optional new_value(float value) override; protected: uint32_t last_input_{0}; diff --git a/esphome/core/automation.h b/esphome/core/automation.h index bbb7f08fb8..e7cf1c1143 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -19,11 +19,12 @@ namespace esphome { /// Codegen wraps constants in stateless lambdas so only a function pointer is needed. template class TemplatableFn { public: - TemplatableFn() = default; + constexpr TemplatableFn() = default; TemplatableFn(std::nullptr_t) = delete; // Exact return type match — direct function pointer storage - template TemplatableFn(F f) requires std::convertible_to : f_(f) {} + // constexpr so codegen can place arrays of these in flash. + template constexpr TemplatableFn(F f) requires std::convertible_to : f_(f) {} // Convertible return type (e.g., int -> uint8_t) — casting trampoline. // Stateless lambdas are default-constructible in C++20, so F{} recreates the lambda inside diff --git a/tests/component_tests/sensor/test_value_list.py b/tests/component_tests/sensor/test_value_list.py new file mode 100644 index 0000000000..168fee2427 --- /dev/null +++ b/tests/component_tests/sensor/test_value_list.py @@ -0,0 +1,43 @@ +"""Tests for the sensor value list filter codegen.""" + +from collections.abc import Callable +import re + + +def test_value_list_filters_share_progmem_tables( + generate_main: Callable[[str], str], +) -> None: + """Identical value lists share one PROGMEM table; the NaN throttle stays array free.""" + main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml") + + tables = re.findall( + r"static constexpr TemplatableFn (\w+)\[\] PROGMEM", main_cpp + ) + # Every table ends with an empty entry so the filters store no count. + assert main_cpp.count("TemplatableFn()}") == len(tables) + assert ( + len(tables) == 5 + ) # single, shared list, lambda, two anchored stateful lambdas + single, shared, lam = tables[:3] + assert f"sensor::FilterOutValueFilter({single});" in main_cpp + assert main_cpp.count(f"sensor::FilterOutValueFilter({shared});") == 2 + assert f"sensor::FilterOutValueFilter({lam});" in main_cpp + assert f"sensor::ThrottleWithPriorityFilter(1000, {shared});" in main_cpp + assert "sensor::ThrottleWithPriorityNanFilter(1000);" in main_cpp + assert "return 42.0;" in main_cpp + + +def test_value_list_lambda_tables_are_not_shared( + generate_main: Callable[[str], str], +) -> None: + """An anchored lambda may keep static state, so each filter keeps its own table.""" + main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml") + + tables = re.findall( + r"static constexpr TemplatableFn (\w+)\[\] PROGMEM = (.*?)TemplatableFn\(\)\}", + main_cpp, + re.DOTALL, + ) + stateful = [name for name, body in tables if "static float n" in body] + assert len(stateful) == 2 + assert stateful[0] != stateful[1] diff --git a/tests/component_tests/sensor/test_value_list.yaml b/tests/component_tests/sensor/test_value_list.yaml new file mode 100644 index 0000000000..60a044f483 --- /dev/null +++ b/tests/component_tests/sensor/test_value_list.yaml @@ -0,0 +1,51 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: template + id: fo_single + lambda: return 1.0; + filters: + - filter_out: 85.0 + - platform: template + id: fo_list_a + lambda: return 1.0; + filters: + - filter_out: [0.0, 85.0] + - platform: template + id: fo_list_b + lambda: return 1.0; + filters: + - filter_out: [0.0, 85.0] + - platform: template + id: fo_lambda + lambda: return 1.0; + filters: + - filter_out: !lambda return 42.0; + - platform: template + id: twp + lambda: return 1.0; + filters: + - throttle_with_priority: + timeout: 1s + value: [0.0, 85.0] + - platform: template + id: twp_nan + lambda: return 1.0; + filters: + - throttle_with_priority: 1s + - platform: template + id: fo_stateful_a + lambda: return 1.0; + filters: + - filter_out: &stateful !lambda |- + static float n = 0; + return ++n; + - platform: template + id: fo_stateful_b + lambda: return 1.0; + filters: + - filter_out: *stateful From d6e5b9b03bd037954e6733c3505d385bc375f7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Tue, 13 Oct 2026 03:50:12 +0200 Subject: [PATCH 261/268] [bq25186] Add the status sensors, buttons, PG/GPO switch and watchdog option (part 2) (#15736) Co-authored-by: J. Nick Koston --- esphome/components/bq25186/__init__.py | 87 ++++++++++++-- esphome/components/bq25186/binary_sensor.py | 73 ++++++++++++ esphome/components/bq25186/bq25186.cpp | 106 +++++++++++++++++- esphome/components/bq25186/bq25186.h | 57 +++++++++- .../bq25186/bq25186_binary_sensor.h | 31 +++++ esphome/components/bq25186/bq25186_button.h | 25 +++++ esphome/components/bq25186/bq25186_switch.h | 21 ++++ .../bq25186/bq25186_text_sensor.cpp | 26 +++++ .../components/bq25186/bq25186_text_sensor.h | 26 +++++ esphome/components/bq25186/button.py | 52 +++++++++ esphome/components/bq25186/switch.py | 27 +++++ esphome/components/bq25186/text_sensor.py | 38 +++++++ esphome/core/defines.h | 3 + .../bq25186/test_pack_registers.py | 50 +++++++++ tests/components/bq25186/common.yaml | 74 ++++++++++++ 15 files changed, 676 insertions(+), 20 deletions(-) create mode 100644 esphome/components/bq25186/binary_sensor.py create mode 100644 esphome/components/bq25186/bq25186_binary_sensor.h create mode 100644 esphome/components/bq25186/bq25186_button.h create mode 100644 esphome/components/bq25186/bq25186_switch.h create mode 100644 esphome/components/bq25186/bq25186_text_sensor.cpp create mode 100644 esphome/components/bq25186/bq25186_text_sensor.h create mode 100644 esphome/components/bq25186/button.py create mode 100644 esphome/components/bq25186/switch.py create mode 100644 esphome/components/bq25186/text_sensor.py diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py index e57860786f..03a100d929 100644 --- a/esphome/components/bq25186/__init__.py +++ b/esphome/components/bq25186/__init__.py @@ -5,8 +5,8 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID -from esphome.core import CORE, ID, Lambda +from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL, CONF_WATCHDOG +from esphome.core import CORE, ID, EsphomeError, Lambda, TimePeriod from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@Rapsssito"] @@ -14,6 +14,7 @@ DEPENDENCIES = ["i2c"] DOMAIN = "bq25186" MULTI_CONF = True +CONF_BQ25186_ID = "bq25186_id" CONF_CONFIGURE_ON_BOOT = "configure_on_boot" CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage" CONF_FAST_CHARGE_CURRENT = "fast_charge_current" @@ -43,6 +44,7 @@ CONF_WAKE2_TIMER = "wake2_timer" CONF_ENABLE_PUSH_BUTTON = "enable_push_button" CONF_SYSTEM_REGULATION = "system_regulation" CONF_SYS_MODE = "sys_mode" +CONF_WATCHDOG_15S_ENABLE = "watchdog_15s_enable" CONF_DISABLE_VDPPM = "disable_vdppm" CONF_TS_HOT = "ts_hot" CONF_TS_COLD = "ts_cold" @@ -56,7 +58,26 @@ CONF_MASK_BAT_INTERRUPT = "mask_bat_interrupt" CONF_MASK_PG_INTERRUPT = "mask_pg_interrupt" bq25186_ns = cg.esphome_ns.namespace("bq25186") -BQ25186Component = bq25186_ns.class_("BQ25186Component", cg.Component, i2c.I2CDevice) +BQ25186Component = bq25186_ns.class_( + "BQ25186Component", cg.PollingComponent, i2c.I2CDevice +) + +BQ25186_CHILD_SCHEMA = cv.Schema( + {cv.GenerateID(CONF_BQ25186_ID): cv.use_id(BQ25186Component)} +) +_request_binary_sensor_slot = cg.slot_counter("BQ25186_BINARY_SENSOR_COUNT") +_request_text_sensor_slot = cg.slot_counter("BQ25186_TEXT_SENSOR_COUNT") + + +def register_binary_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: + _request_binary_sensor_slot(str(hub)) + return hub.register_binary_sensor(var) + + +def register_text_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: + _request_text_sensor_slot(str(hub)) + return hub.register_text_sensor(var) + REG_VBAT_CTRL = 0x03 REG_ICHG_CTRL = 0x04 @@ -69,7 +90,6 @@ REG_SYS_REG = 0x0A REG_TS_CONTROL = 0x0B REG_MASK_ID = 0x0C REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1 -WATCHDOG_DISABLED = 0x03 TERMINATION_PERCENT_OPTIONS = {"disabled": 0, "5%": 1, "10%": 2, "20%": 3} VINDPM_MODE_OPTIONS = {"battery_tracking": 0, "4.5v": 1, "4.7v": 2, "disabled": 3} @@ -86,6 +106,22 @@ BATTERY_UVLO_OPTIONS = { VLOWV_OPTIONS = {"3.0v": 0, "2.8v": 1} RECHARGE_THRESHOLD_OPTIONS = {"100mv": 0, "200mv": 1} SAFETY_TIMER_OPTIONS = {"3h": 0, "6h": 1, "12h": 2, "disabled": 3} +# The I2C watchdog restores the power-on registers when the bus stays quiet for its period +WATCHDOG_OPTIONS = { + "160s_software_reset": 0, + "160s_hardware_reset": 1, + "40s_hardware_reset": 2, + "disabled": 3, +} +WATCHDOG_PERIOD_S = { + "160s_software_reset": 160, + "160s_hardware_reset": 160, + "40s_hardware_reset": 40, +} +# SYS_REG watchdog_15s_enable: a hardware reset when the bus stays quiet for 15 s after VIN connects +WATCHDOG_15S_PERIOD_S = 15 +# The chip boots with the 160 s software reset watchdog, which runs until the configuration is applied +POWER_ON_WATCHDOG_PERIOD_S = 160 LONG_PRESS_DURATION_OPTIONS = {"5s": 0, "10s": 1, "15s": 2, "20s": 3} AUTOWAKE_OPTIONS = {"0.5s": 0, "1s": 1, "2s": 2, "4s": 3} INPUT_CURRENT_LIMIT_OPTIONS = { @@ -146,8 +182,8 @@ def _on_current_step(amps: float) -> float: # (config key, register, bit shift, validator or enum options, default). -# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; they are -# driven at runtime, not from configuration. +# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; the PG/GPO +# switch drives them. FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( ( CONF_BATTERY_REGULATION_VOLTAGE, @@ -179,6 +215,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( (CONF_RECHARGE_THRESHOLD, REG_IC_CTRL, 5, RECHARGE_THRESHOLD_OPTIONS, "100mv"), (CONF_DOUBLE_TIMER_DURING_DPM, REG_IC_CTRL, 4, cv.boolean, False), (CONF_SAFETY_TIMER, REG_IC_CTRL, 2, SAFETY_TIMER_OPTIONS, "6h"), + (CONF_WATCHDOG, REG_IC_CTRL, 0, WATCHDOG_OPTIONS, "disabled"), (CONF_LONG_PRESS_DURATION, REG_TMR_ILIM, 6, LONG_PRESS_DURATION_OPTIONS, "10s"), (CONF_HW_RESET_REQUIRES_VIN, REG_TMR_ILIM, 5, cv.boolean, False), (CONF_AUTOWAKE, REG_TMR_ILIM, 3, AUTOWAKE_OPTIONS, "1s"), @@ -195,6 +232,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( (CONF_ENABLE_PUSH_BUTTON, REG_SHIP_RST, 0, cv.boolean, True), (CONF_SYSTEM_REGULATION, REG_SYS_REG, 5, SYSTEM_REGULATION_OPTIONS, "4.5v"), (CONF_SYS_MODE, REG_SYS_REG, 2, SYS_MODE_OPTIONS, "normal"), + (CONF_WATCHDOG_15S_ENABLE, REG_SYS_REG, 1, cv.boolean, False), (CONF_DISABLE_VDPPM, REG_SYS_REG, 0, cv.boolean, False), (CONF_TS_HOT, REG_TS_CONTROL, 6, TS_HOT_OPTIONS, "60c"), (CONF_TS_COLD, REG_TS_CONTROL, 4, TS_COLD_OPTIONS, "0c"), @@ -232,9 +270,6 @@ _ENCODERS: dict[str, Callable[[Any], int]] = { def pack_registers(config: ConfigType) -> list[int]: """Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes.""" regs = [0] * REGISTER_COUNT - # Watchdog disabled (IC_CTRL[1:0] = 3): nothing talks to the chip after boot yet, and a running - # watchdog restores the power-on defaults after 160 s. Part 2 adds the option with the polling. - regs[REG_IC_CTRL - REG_VBAT_CTRL] = WATCHDOG_DISABLED for key, register, shift, validator, _ in FIELDS: value = config[key] if isinstance(validator, dict): @@ -259,7 +294,28 @@ def _field_schema(with_defaults: bool) -> cv.Schema: BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True) -CONFIG_SCHEMA = ( + +def _check_watchdog(config: ConfigType) -> ConfigType: + """Only the status poll talks to the chip, so it has to come round before any watchdog does.""" + periods = [] + if (period := WATCHDOG_PERIOD_S.get(config[CONF_WATCHDOG])) is not None: + periods.append(period) + if config[CONF_WATCHDOG_15S_ENABLE]: + periods.append(WATCHDOG_15S_PERIOD_S) + if not config.get(CONF_CONFIGURE_ON_BOOT, True): + periods.append(POWER_ON_WATCHDOG_PERIOD_S) + if not periods: + return config + if config[CONF_UPDATE_INTERVAL] >= TimePeriod(seconds=(limit := min(periods))): + raise cv.Invalid( + f"{CONF_UPDATE_INTERVAL} must be shorter than {limit} s, or the " + f"watchdog resets the charger between status polls", + path=[CONF_UPDATE_INTERVAL], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(BQ25186Component), @@ -267,7 +323,9 @@ CONFIG_SCHEMA = ( } ) .extend(BQ25186_CONFIG_SCHEMA) - .extend(i2c.i2c_device_schema(0x6A)) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x6A)), + _check_watchdog, ) CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( @@ -305,7 +363,12 @@ async def bq25186_configure_to_code( # Options the action leaves out fall back to the component's configuration path = CORE.config.get_path_for_id(config[CONF_ID])[:-1] parent_config = CORE.config.get_config_for_path(path) - regs = pack_registers({**parent_config, **config}) + merged = {**parent_config, **config} + try: + _check_watchdog(merged) + except cv.Invalid as err: + raise EsphomeError(f"bq25186.apply_configuration: {err.msg}") from err + regs = pack_registers(merged) text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs)))) lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) diff --git a/esphome/components/bq25186/binary_sensor.py b/esphome/components/bq25186/binary_sensor.py new file mode 100644 index 0000000000..fb741950e2 --- /dev/null +++ b/esphome/components/bq25186/binary_sensor.py @@ -0,0 +1,73 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PLUG, + DEVICE_CLASS_RUNNING, +) +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_binary_sensor + +DEPENDENCIES = ["bq25186"] + +BQ25186BinarySensor = bq25186_ns.class_( + "BQ25186BinarySensor", binary_sensor.BinarySensor +) + +REG_STAT0 = 0 +REG_STAT1 = 1 +REG_FLAG0 = 2 +# Bit set for each field value that reads as on: a single bit is on when it is 1 +_BIT_SET = 0b10 + +# (key, status register, bit shift, field mask, on-values bitmap, device class) +_FIELDS = ( + ("ts", REG_STAT0, 7, 0x01, _BIT_SET, DEVICE_CLASS_OPENING), + ("charging", REG_STAT0, 5, 0x03, 0b0110, DEVICE_CLASS_BATTERY_CHARGING), + ("charge_done", REG_STAT0, 5, 0x03, 0b1000, cv.UNDEFINED), + ("ilim", REG_STAT0, 4, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("vdppm", REG_STAT0, 3, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("vindpm", REG_STAT0, 2, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("thermreg", REG_STAT0, 1, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("power_good", REG_STAT0, 0, 0x01, _BIT_SET, DEVICE_CLASS_PLUG), + ("vin_ovp", REG_STAT1, 7, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("battery_uvlo", REG_STAT1, 6, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("safety_timer_flag", REG_STAT1, 2, 0x01, _BIT_SET, cv.UNDEFINED), + ("wake1_flag", REG_STAT1, 1, 0x01, _BIT_SET, cv.UNDEFINED), + ("wake2_flag", REG_STAT1, 0, 0x01, _BIT_SET, cv.UNDEFINED), + ("ts_flag", REG_FLAG0, 7, 0x01, _BIT_SET, cv.UNDEFINED), + ("ilim_flag", REG_FLAG0, 6, 0x01, _BIT_SET, cv.UNDEFINED), + ("vdppm_flag", REG_FLAG0, 5, 0x01, _BIT_SET, cv.UNDEFINED), + ("vindpm_flag", REG_FLAG0, 4, 0x01, _BIT_SET, cv.UNDEFINED), + ("thermreg_flag", REG_FLAG0, 3, 0x01, _BIT_SET, cv.UNDEFINED), + ("vin_ovp_flag", REG_FLAG0, 2, 0x01, _BIT_SET, cv.UNDEFINED), + ("battery_uvlo_flag", REG_FLAG0, 1, 0x01, _BIT_SET, cv.UNDEFINED), + ("battery_ocp_flag", REG_FLAG0, 0, 0x01, _BIT_SET, cv.UNDEFINED), +) + + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): binary_sensor.binary_sensor_schema( + BQ25186BinarySensor, device_class=device_class + ) + for key, *_, device_class in _FIELDS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + for key, reg, shift, mask, match, _ in _FIELDS: + await sensors( + key, partial(register_binary_sensor, hub), reg, shift, mask, match + ) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp index e660ec63d1..edea50d111 100644 --- a/esphome/components/bq25186/bq25186.cpp +++ b/esphome/components/bq25186/bq25186.cpp @@ -1,4 +1,10 @@ #include "bq25186.h" +#ifdef USE_BINARY_SENSOR +#include "bq25186_binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "bq25186_text_sensor.h" +#endif #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -7,10 +13,10 @@ namespace esphome::bq25186 { ESPHOME_LOG_TAG(TAG, "bq25186"); -// Bits the configuration owns in each register; the rest are runtime state (VBAT_CTRL pg_mode, -// SYS_REG GPO level), reset triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read +// Bits the configuration owns in each register; the rest are the SYS_REG GPO level, reset +// triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = { - 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, }; bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) { @@ -31,16 +37,38 @@ bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value return this->write_register_(reg, reg_value); } +uint8_t BQ25186Component::expected_register_(size_t index) const { + uint8_t value = this->regs_[index]; +#ifdef USE_BQ25186_PG_GPO + if (index == 0 && this->pg_gpo_switch_ != nullptr) + value |= BQ25186_VBAT_CTRL_PG_MODE; +#endif + return value; +} + +bool BQ25186Component::config_matches_(const BQ25186StatusRegisters ®s) const { + for (uint8_t reg = BQ25186_REG_VBAT_CTRL; reg <= BQ25186_REG_MASK_ID; reg++) { + const size_t index = reg - BQ25186_REG_VBAT_CTRL; + const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[index]); + if (((regs[reg] ^ this->expected_register_(index)) & mask) != 0) + return false; + } + return true; +} + bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { // Setup rejects an unknown chip; the action must not program it before that check or after it failed if (!this->is_ready() || this->is_failed()) { ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded"); return false; } + this->regs_ = regs; + this->configured_ = true; for (size_t i = 0; i < regs.size(); i++) { const uint8_t reg = BQ25186_REG_VBAT_CTRL + i; const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]); - const bool ok = mask == 0xFF ? this->write_register_(reg, regs[i]) : this->update_register_(reg, mask, regs[i]); + const uint8_t value = this->expected_register_(i); + const bool ok = mask == 0xFF ? this->write_register_(reg, value) : this->update_register_(reg, mask, value); if (!ok) { this->status_set_warning(LOG_STR("Configuration partly applied")); return false; @@ -68,12 +96,80 @@ void BQ25186Component::setup() { // A failed write only sets the warning status: the chip has identified itself, so a later // apply_configuration may retry if (this->configure_on_boot_) - this->apply_config(this->setup_regs_); + this->apply_config(this->regs_); +#ifdef USE_BQ25186_PG_GPO + if (this->pg_gpo_switch_ != nullptr) { + // The poll below puts the pin in output mode; without a restore mode the switch takes its level from it + auto initial = this->pg_gpo_switch_->get_initial_state_with_restore_mode(); + if (initial.has_value()) + this->pg_gpo_switch_->publish_state(*initial); // the first poll writes it to the pin + } +#endif + // The poller first runs after a random share of the interval; the entities get a state now + this->update(); +} + +#ifdef USE_BQ25186_PG_GPO +// The pin mode and level come from the switch, so every poll puts back whatever the chip lost +void BQ25186Component::check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending) { + if (this->pg_gpo_switch_ == nullptr) + return; + if ((status[BQ25186_REG_VBAT_CTRL] & BQ25186_VBAT_CTRL_PG_MODE) == 0 && + !this->update_register_(BQ25186_REG_VBAT_CTRL, BQ25186_VBAT_CTRL_PG_MODE, BQ25186_VBAT_CTRL_PG_MODE)) { + this->status_set_warning(LOG_STR("PG/GPO pin not in output mode")); + pending = true; + } + const bool level = (status[BQ25186_REG_SYS_REG] & BQ25186_SYS_REG_PG_GPO_LEVEL) != 0; + if (!this->pg_gpo_switch_->has_state()) { + this->pg_gpo_switch_->publish_state(level); + } else if (level != this->pg_gpo_switch_->state && !this->write_pg_gpo_level(this->pg_gpo_switch_->state)) { + this->status_set_warning(LOG_STR("PG/GPO level write failed")); + pending = true; + } +} +#endif + +// Every poll also serves the I2C watchdog, which wants to see traffic within its period +void BQ25186Component::update() { + BQ25186StatusRegisters status; + if (!this->read_bytes(BQ25186_REG_STAT0, status.data(), status.size())) { + this->status_set_warning(LOG_STR("Status read failed")); + return; + } + // Whatever failed is tried again here, so the warning only stays while something is still wrong + bool pending = false; + // A watchdog or hardware reset puts the power-on values back, and a failed write leaves a register + // behind; either way the configuration is written again + if (this->configured_ && !this->config_matches_(status)) { + ESP_LOGW(TAG, "Configuration registers differ from the values applied, writing them again"); + if (this->apply_config(this->regs_)) { + status[BQ25186_REG_VBAT_CTRL] |= BQ25186_VBAT_CTRL_PG_MODE; // part of the write when a switch is configured + } else { + pending = true; + } + } +#ifdef USE_BQ25186_PG_GPO + this->check_pg_gpo_(status, pending); +#endif + if (!pending) + this->status_clear_warning(); +#ifdef BQ25186_BINARY_SENSOR_COUNT + for (auto *sensor : this->binary_sensors_) + sensor->on_status(status); +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + for (auto *sensor : this->text_sensors_) + sensor->on_status(status); +#endif } void BQ25186Component::dump_config() { ESP_LOGCONFIG(TAG, "BQ25186:"); LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +#ifdef USE_BQ25186_PG_GPO + LOG_SWITCH(" ", "PG/GPO", this->pg_gpo_switch_); +#endif } } // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h index a692087147..0a9a45c28b 100644 --- a/esphome/components/bq25186/bq25186.h +++ b/esphome/components/bq25186/bq25186.h @@ -2,11 +2,19 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#ifdef USE_BQ25186_PG_GPO +#include "esphome/components/switch/switch.h" +#endif #include namespace esphome::bq25186 { +static constexpr uint8_t BQ25186_REG_STAT0 = 0x00; +static constexpr uint8_t BQ25186_REG_STAT1 = 0x01; +static constexpr uint8_t BQ25186_REG_FLAG0 = 0x02; static constexpr uint8_t BQ25186_REG_VBAT_CTRL = 0x03; static constexpr uint8_t BQ25186_REG_ICHG_CTRL = 0x04; static constexpr uint8_t BQ25186_REG_CHARGECTRL0 = 0x05; @@ -18,27 +26,70 @@ static constexpr uint8_t BQ25186_REG_SYS_REG = 0x0A; static constexpr uint8_t BQ25186_REG_TS_CONTROL = 0x0B; static constexpr uint8_t BQ25186_REG_MASK_ID = 0x0C; static constexpr size_t BQ25186_CONFIG_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_VBAT_CTRL + 1; +// STAT0 to MASK_ID, read together by every poll: the status for the entities, then the configuration +// registers, which show whether the chip was reset or a write failed +static constexpr size_t BQ25186_STATUS_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_STAT0 + 1; + +// VBAT_CTRL pg_mode: the /PG pin is a general purpose output driven by SYS_REG pg_gpo_level +static constexpr uint8_t BQ25186_VBAT_CTRL_PG_MODE = 0x80; +static constexpr uint8_t BQ25186_SYS_REG_PG_GPO_LEVEL = 0x10; // Register values for VBAT_CTRL..MASK_ID, packed by code generation using BQ25186ConfigRegisters = std::array; +using BQ25186StatusRegisters = std::array; -class BQ25186Component : public Component, public i2c::I2CDevice { +class BQ25186BinarySensor; +class BQ25186TextSensor; + +class BQ25186Component : public PollingComponent, public i2c::I2CDevice { public: void setup() override; + void update() override; void dump_config() override; void set_setup_registers(const BQ25186ConfigRegisters ®s) { - this->setup_regs_ = regs; + this->regs_ = regs; this->configure_on_boot_ = true; } bool apply_config(const BQ25186ConfigRegisters ®s); +#ifdef BQ25186_BINARY_SENSOR_COUNT + void register_binary_sensor(BQ25186BinarySensor *sensor) { this->binary_sensors_.push_back(sensor); } +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + void register_text_sensor(BQ25186TextSensor *sensor) { this->text_sensors_.push_back(sensor); } +#endif +#ifdef USE_BQ25186_PG_GPO + /// The /PG/GPO pin is put in general purpose output mode when a switch drives it + void set_pg_gpo_switch(switch_::Switch *pg_gpo_switch) { this->pg_gpo_switch_ = pg_gpo_switch; } + bool write_pg_gpo_level(bool level) { + return this->update_register_(BQ25186_REG_SYS_REG, BQ25186_SYS_REG_PG_GPO_LEVEL, + level ? BQ25186_SYS_REG_PG_GPO_LEVEL : 0); + } +#endif + /// Write the masked bits of SHIP_RST + bool write_ship_rst(uint8_t mask, uint8_t value) { return this->update_register_(BQ25186_REG_SHIP_RST, mask, value); } protected: bool write_register_(uint8_t reg, uint8_t value); + uint8_t expected_register_(size_t index) const; + bool config_matches_(const BQ25186StatusRegisters ®s) const; +#ifdef USE_BQ25186_PG_GPO + void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending); +#endif bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); - BQ25186ConfigRegisters setup_regs_{}; +#ifdef BQ25186_BINARY_SENSOR_COUNT + StaticVector binary_sensors_; +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + StaticVector text_sensors_; +#endif +#ifdef USE_BQ25186_PG_GPO + switch_::Switch *pg_gpo_switch_{nullptr}; +#endif + BQ25186ConfigRegisters regs_{}; // the configuration last applied, written again after a chip reset bool configure_on_boot_{false}; + bool configured_{false}; // regs_ went to the chip at least once }; } // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186_binary_sensor.h b/esphome/components/bq25186/bq25186_binary_sensor.h new file mode 100644 index 0000000000..265f34a445 --- /dev/null +++ b/esphome/components/bq25186/bq25186_binary_sensor.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR + +#include "bq25186.h" +#include "esphome/components/binary_sensor/binary_sensor.h" + +namespace esphome::bq25186 { + +/// One field of a status register; `match` has a bit set for each field value that reads as on +class BQ25186BinarySensor final : public binary_sensor::BinarySensor { + public: + BQ25186BinarySensor(uint8_t reg, uint8_t shift, uint8_t mask, uint8_t match) + : reg_(reg), shift_(shift), mask_(mask), match_(match) {} + + void on_status(const BQ25186StatusRegisters &status) { + const uint8_t value = (status[this->reg_] >> this->shift_) & this->mask_; + this->publish_state((this->match_ >> value) & 1); + } + + protected: + const uint8_t reg_; + const uint8_t shift_; + const uint8_t mask_; + const uint8_t match_; +}; + +} // namespace esphome::bq25186 + +#endif // USE_BINARY_SENSOR diff --git a/esphome/components/bq25186/bq25186_button.h b/esphome/components/bq25186/bq25186_button.h new file mode 100644 index 0000000000..e088166ac6 --- /dev/null +++ b/esphome/components/bq25186/bq25186_button.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BUTTON + +#include "bq25186.h" +#include "esphome/components/button/button.h" + +namespace esphome::bq25186 { + +/// Writes one SHIP_RST field when pressed +class BQ25186Button final : public button::Button, public Parented { + public: + BQ25186Button(uint8_t mask, uint8_t value) : mask_(mask), value_(value) {} + + protected: + void press_action() override { this->parent_->write_ship_rst(this->mask_, this->value_); } + + const uint8_t mask_; + const uint8_t value_; +}; + +} // namespace esphome::bq25186 + +#endif // USE_BUTTON diff --git a/esphome/components/bq25186/bq25186_switch.h b/esphome/components/bq25186/bq25186_switch.h new file mode 100644 index 0000000000..9abe6fec51 --- /dev/null +++ b/esphome/components/bq25186/bq25186_switch.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BQ25186_PG_GPO + +#include "bq25186.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::bq25186 { + +class BQ25186PgGpoSwitch final : public switch_::Switch, public Parented { + protected: + void write_state(bool state) override { + if (this->parent_->write_pg_gpo_level(state)) + this->publish_state(state); + } +}; + +} // namespace esphome::bq25186 + +#endif // USE_BQ25186_PG_GPO diff --git a/esphome/components/bq25186/bq25186_text_sensor.cpp b/esphome/components/bq25186/bq25186_text_sensor.cpp new file mode 100644 index 0000000000..660313e641 --- /dev/null +++ b/esphome/components/bq25186/bq25186_text_sensor.cpp @@ -0,0 +1,26 @@ +#include "bq25186_text_sensor.h" +#ifdef USE_TEXT_SENSOR + +#include "esphome/core/progmem.h" + +namespace esphome::bq25186 { + +// STAT0[6:5] charge status, then STAT1[4:3] TS status +PROGMEM_STRING_TABLE(StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", "Charge Done/Disabled", + "Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", "Warm (Voltage Reduced)"); + +void BQ25186TextSensor::on_status(const BQ25186StatusRegisters &status) { + const uint8_t value = (status[this->reg_] >> this->shift_) & 0x03; + if (value == this->last_) + return; + this->last_ = value; + const ProgmemStr name = StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX); + char text[32]; + ESPHOME_strncpy_P(text, reinterpret_cast(name), sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->publish_state(text); +} + +} // namespace esphome::bq25186 + +#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/bq25186_text_sensor.h b/esphome/components/bq25186/bq25186_text_sensor.h new file mode 100644 index 0000000000..33c8f806fa --- /dev/null +++ b/esphome/components/bq25186/bq25186_text_sensor.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_TEXT_SENSOR + +#include "bq25186.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::bq25186 { + +/// A two-bit status field; `first` is the string table index of the field's value 0 +class BQ25186TextSensor final : public text_sensor::TextSensor { + public: + BQ25186TextSensor(uint8_t reg, uint8_t shift, uint8_t first) : reg_(reg), shift_(shift), first_(first) {} + void on_status(const BQ25186StatusRegisters &status); + + protected: + const uint8_t reg_; + const uint8_t shift_; + const uint8_t first_; + uint8_t last_{0xFF}; // the field value last published +}; + +} // namespace esphome::bq25186 + +#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/button.py b/esphome/components/bq25186/button.py new file mode 100644 index 0000000000..25527ff9a2 --- /dev/null +++ b/esphome/components/bq25186/button.py @@ -0,0 +1,52 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ICON_POWER, + ICON_RESTART, + ICON_RESTART_ALERT, +) +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns + +DEPENDENCIES = ["bq25186"] + +BQ25186Button = bq25186_ns.class_("BQ25186Button", button.Button) + +# SHIP_RST: bit 7 is the software reset, bits 6:5 the shutdown, ship and hardware reset codes +SOFTWARE_RESET_MASK = 0x80 +MODE_MASK = 0x60 + +# (key, SHIP_RST mask, value, device class, icon) +_BUTTONS = ( + ("software_reset", SOFTWARE_RESET_MASK, 0x80, DEVICE_CLASS_RESTART, ICON_RESTART), + ("shutdown", MODE_MASK, 0x20, cv.UNDEFINED, ICON_POWER), + ("ship_mode", MODE_MASK, 0x40, cv.UNDEFINED, ICON_POWER), + ("hardware_reset", MODE_MASK, 0x60, DEVICE_CLASS_RESTART, ICON_RESTART_ALERT), +) + + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): button.button_schema( + BQ25186Button, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=icon, + device_class=device_class, + ) + for key, _, _, device_class, icon in _BUTTONS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _BUTTONS)), +) + + +async def to_code(config: ConfigType) -> None: + for key, mask, value, _, _ in _BUTTONS: + if (conf := config.get(key)) is not None: + var = await button.new_button(conf, mask, value) + await cg.register_parented(var, config[CONF_BQ25186_ID]) diff --git a/esphome/components/bq25186/switch.py b/esphome/components/bq25186/switch.py new file mode 100644 index 0000000000..e5eb2d98a2 --- /dev/null +++ b/esphome/components/bq25186/switch.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns + +DEPENDENCIES = ["bq25186"] + +CONF_PG_GPO = "pg_gpo" + +BQ25186PgGpoSwitch = bq25186_ns.class_("BQ25186PgGpoSwitch", switch.Switch) + +CONFIG_SCHEMA = BQ25186_CHILD_SCHEMA.extend( + { + cv.Required(CONF_PG_GPO): switch.switch_schema( + BQ25186PgGpoSwitch, default_restore_mode="DISABLED" + ), + } +) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_BQ25186_PG_GPO") + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_PG_GPO, hub.set_pg_gpo_switch) diff --git a/esphome/components/bq25186/text_sensor.py b/esphome/components/bq25186/text_sensor.py new file mode 100644 index 0000000000..b27d2fbcfa --- /dev/null +++ b/esphome/components/bq25186/text_sensor.py @@ -0,0 +1,38 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_text_sensor + +DEPENDENCIES = ["bq25186"] + +BQ25186TextSensor = bq25186_ns.class_("BQ25186TextSensor", text_sensor.TextSensor) + +REG_STAT0 = 0 +REG_STAT1 = 1 + +# (key, status register, bit shift, index of the field's value 0 in the C++ string table) +_FIELDS = ( + ("charge_status", REG_STAT0, 5, 0), + ("ts_status", REG_STAT1, 3, 4), +) + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): text_sensor.text_sensor_schema(BQ25186TextSensor) + for key, *_ in _FIELDS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + sensors = text_sensor.sub_text_sensors(config) + for key, reg, shift, first in _FIELDS: + await sensors(key, partial(register_text_sensor, hub), reg, shift, first) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c645428c7c..072abe8374 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -49,6 +49,9 @@ #define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK #define USE_BMI270_BMM150 +#define USE_BQ25186_PG_GPO +#define BQ25186_BINARY_SENSOR_COUNT 1 +#define BQ25186_TEXT_SENSOR_COUNT 1 #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE diff --git a/tests/component_tests/bq25186/test_pack_registers.py b/tests/component_tests/bq25186/test_pack_registers.py index 2ebf6087e7..940bf5822f 100644 --- a/tests/component_tests/bq25186/test_pack_registers.py +++ b/tests/component_tests/bq25186/test_pack_registers.py @@ -10,12 +10,16 @@ from esphome.components.bq25186 import ( CONF_FAST_CHARGE_CURRENT, CONF_MASK_TS_INTERRUPT, CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + CONF_WATCHDOG_15S_ENABLE, + CONFIG_SCHEMA, REG_ICHG_CTRL, REG_MASK_ID, REG_SHIP_RST, REG_VBAT_CTRL, + _check_watchdog, pack_registers, ) +from esphome.const import CONF_UPDATE_INTERVAL, CONF_WATCHDOG from esphome.types import ConfigType # Datasheet power-on values for VBAT_CTRL..MASK_ID, except that IC_CTRL has the @@ -85,3 +89,49 @@ def test_masked_registers_only_carry_configurable_bits() -> None: def test_values_between_steps_are_rejected(key: str, value: str) -> None: with pytest.raises(cv.Invalid): BQ25186_CONFIG_SCHEMA({key: value}) + + +def _watchdog_config(interval: str, options: ConfigType) -> ConfigType: + config = BQ25186_CONFIG_SCHEMA(options) + config[CONF_UPDATE_INTERVAL] = cv.update_interval(interval) + return config + + +@pytest.mark.parametrize( + "interval, options", + [ + ("60s", {CONF_WATCHDOG: "160s_software_reset"}), + ("60s", {CONF_WATCHDOG: "160s_hardware_reset"}), + ("30s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("60s", {CONF_WATCHDOG: "disabled"}), + ("10s", {CONF_WATCHDOG_15S_ENABLE: True}), + ], +) +def test_update_interval_within_watchdog_passes( + interval: str, options: ConfigType +) -> None: + _check_watchdog(_watchdog_config(interval, options)) + + +@pytest.mark.parametrize( + "interval, options", + [ + ("60s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("40s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("60s", {CONF_WATCHDOG_15S_ENABLE: True}), + ("20s", {CONF_WATCHDOG: "160s_software_reset", CONF_WATCHDOG_15S_ENABLE: True}), + ("never", {CONF_WATCHDOG: "160s_software_reset"}), + ], +) +def test_update_interval_past_watchdog_is_rejected( + interval: str, options: ConfigType +) -> None: + with pytest.raises(cv.Invalid, match=CONF_UPDATE_INTERVAL): + _check_watchdog(_watchdog_config(interval, options)) + + +def test_unconfigured_chip_keeps_its_power_on_watchdog() -> None: + config = {"configure_on_boot": False, "update_interval": "160s"} + with pytest.raises(cv.Invalid, match="shorter than 160 s"): + CONFIG_SCHEMA(config) + CONFIG_SCHEMA({**config, "update_interval": "60s"}) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml index 9f4b117cd3..295a68d262 100644 --- a/tests/components/bq25186/common.yaml +++ b/tests/components/bq25186/common.yaml @@ -23,3 +23,77 @@ bq25186: address: 0x6B configure_on_boot: true fast_charge_current: 20mA + watchdog: 160s_software_reset + update_interval: 30s + +binary_sensor: + - platform: bq25186 + bq25186_id: bq25186_charger + ts: + name: TS Open + charging: + name: Charging + charge_done: + name: Charge Done + ilim: + name: ILIM Active + vdppm: + name: VDPPM Active + vindpm: + name: VINDPM Active + thermreg: + name: Thermal Regulation Active + power_good: + name: VIN Power Good + vin_ovp: + name: VIN OVP Active + battery_uvlo: + name: Battery UVLO Active + safety_timer_flag: + name: Safety Timer Flag + wake1_flag: + name: Wake 1 Flag + wake2_flag: + name: Wake 2 Flag + ts_flag: + name: TS Flag + ilim_flag: + name: ILIM Flag + vdppm_flag: + name: VDPPM Flag + vindpm_flag: + name: VINDPM Flag + thermreg_flag: + name: Thermal Regulation Flag + vin_ovp_flag: + name: VIN OVP Flag + battery_uvlo_flag: + name: Battery UVLO Flag + battery_ocp_flag: + name: Battery OCP Flag + +text_sensor: + - platform: bq25186 + bq25186_id: bq25186_charger + charge_status: + name: Charge Status + ts_status: + name: TS Status + +button: + - platform: bq25186 + bq25186_id: bq25186_charger + software_reset: + name: Software Reset + shutdown: + name: Shutdown + ship_mode: + name: Ship Mode + hardware_reset: + name: Hardware Reset + +switch: + - platform: bq25186 + bq25186_id: bq25186_boot + pg_gpo: + name: PG GPO From 98d3d23ce14d802c8429c14067da77b8b467b474 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:59:25 -1000 Subject: [PATCH 262/268] [ecocomfort2] Test the component in isolation (#20613) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 8bbb9ed7f9..f7680f105c 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -83,6 +83,7 @@ ISOLATED_COMPONENTS = { "animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display", "cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host", "esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged", + "ecocomfort2": "Needs esp32_ble with io_capability: none and sc_bond, which a merged esp32_ble: block overrides", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", "gps": "TinyGPSPlus library declares millis() function that creates ambiguity with ESPHome millis() macro when merged with components using millis() in lambdas", From 753c10e5eace236ad573f0c1b668277a77ecae61 Mon Sep 17 00:00:00 2001 From: Ivan Novak <37712440+NovakIrs@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:06:45 +0200 Subject: [PATCH 263/268] [ld2410s] Add the distance sensor, firmware version and common parameters (Part 2) (#8486) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston --- esphome/components/ld2410s/__init__.py | 2 + esphome/components/ld2410s/ld2410s.cpp | 309 +++++++++++++++--- esphome/components/ld2410s/ld2410s.h | 84 ++++- esphome/components/ld2410s/ld2410s_number.h | 32 ++ esphome/components/ld2410s/ld2410s_select.h | 21 ++ esphome/components/ld2410s/number.py | 108 ++++++ esphome/components/ld2410s/select.py | 31 ++ esphome/components/ld2410s/sensor.py | 32 ++ esphome/components/ld2410s/text_sensor.py | 23 ++ esphome/core/defines.h | 2 + tests/components/ld2410s/common-bare.yaml | 28 ++ tests/components/ld2410s/common.yaml | 28 ++ .../ld2410s/test-bare.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_ld2410s.yaml | 62 ++++ .../fixtures/uart_mock_ld2410s_link_lost.yaml | 19 ++ tests/integration/test_uart_mock_ld2410s.py | 81 ++++- 16 files changed, 808 insertions(+), 57 deletions(-) create mode 100644 esphome/components/ld2410s/ld2410s_number.h create mode 100644 esphome/components/ld2410s/ld2410s_select.h create mode 100644 esphome/components/ld2410s/number.py create mode 100644 esphome/components/ld2410s/select.py create mode 100644 esphome/components/ld2410s/sensor.py create mode 100644 esphome/components/ld2410s/text_sensor.py create mode 100644 tests/components/ld2410s/common-bare.yaml create mode 100644 tests/components/ld2410s/test-bare.esp8266-ard.yaml diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py index 1c39c7eab3..ecc90455d1 100644 --- a/esphome/components/ld2410s/__init__.py +++ b/esphome/components/ld2410s/__init__.py @@ -4,6 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType +AUTO_LOAD = ["ld24xx"] CODEOWNERS = ["@NovakIrs"] DEPENDENCIES = ["uart"] DOMAIN = "ld2410s" @@ -13,6 +14,7 @@ CONF_LD2410S_ID = "ld2410s_id" ld2410s_ns = cg.esphome_ns.namespace(DOMAIN) LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice) +Parameter = ld2410s_ns.enum("Parameter", is_class=True) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/ld2410s/ld2410s.cpp b/esphome/components/ld2410s/ld2410s.cpp index 6462236ac9..3f71c77a62 100644 --- a/esphome/components/ld2410s/ld2410s.cpp +++ b/esphome/components/ld2410s/ld2410s.cpp @@ -1,4 +1,5 @@ #include "ld2410s.h" +#include "ld2410s_number.h" #include #include #include "esphome/core/application.h" @@ -25,18 +26,46 @@ static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word static constexpr uint16_t CONFIG_MODE_START_CMD = 0x00FF; static constexpr uint16_t CONFIG_MODE_START_VALUE = 0x0001; static constexpr uint16_t CONFIG_MODE_END_CMD = 0x00FE; +static constexpr uint16_t VERSION_READ_CMD = 0x0000; +static constexpr uint16_t PARAMETERS_WRITE_CMD = 0x0070; +static constexpr uint16_t PARAMETERS_READ_CMD = 0x0071; static constexpr uint16_t OUTPUT_MODE_SWITCH_CMD = 0x007A; // Minimal output mode (short frames carrying presence and distance only) is selected by six zero bytes static constexpr uint8_t OUTPUT_MODE_MINIMAL_SIZE = 6; -static constexpr uint8_t CMD_FRAME_MAX_SIZE = - LONG_PAYLOAD_POS + 2 + OUTPUT_MODE_MINIMAL_SIZE + sizeof(CMD_FRAME_FOOTER); +// The parameter write carries a 16-bit key and a 32-bit value per parameter, the read only the keys +static constexpr uint8_t PARAMETER_KEY_SIZE = 2; +static constexpr uint8_t PARAMETER_VALUE_SIZE = 4; +static constexpr uint8_t PARAMETERS_WRITE_SIZE = PARAMETER_COUNT * (PARAMETER_KEY_SIZE + PARAMETER_VALUE_SIZE); +static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_WRITE_SIZE + sizeof(CMD_FRAME_FOOTER); +// Keys of the parameters, in Parameter order +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B}; +static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter"); +#endif +// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words +static constexpr uint8_t VERSION_TYPE_SIZE = 4; +static constexpr uint8_t VERSION_SIZE = VERSION_TYPE_SIZE + 3 * 2; -// Everything the module needs at start: enter configuration mode, pick the output mode, leave it -static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, OUTPUT_MODE_SWITCH_CMD, - CONFIG_MODE_END_CMD}; +// Everything the module needs at start: configuration mode on, read the version and parameters when +// an entity shows them, pick the output mode, configuration mode off +static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = { + CONFIG_MODE_START_CMD, +#ifdef USE_LD2410S_VERSION + VERSION_READ_CMD, +#endif +#ifdef USE_LD2410S_PARAMETERS + PARAMETERS_READ_CMD, +#endif + OUTPUT_MODE_SWITCH_CMD, CONFIG_MODE_END_CMD, +}; +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint16_t WRITE_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, PARAMETERS_WRITE_CMD, CONFIG_MODE_END_CMD}; +#endif static constexpr uint8_t INIT_SEQUENCE_LENGTH = sizeof(INIT_SEQUENCE) / sizeof(INIT_SEQUENCE[0]); -static uint16_t init_command(uint8_t step) { return progmem_read_uint16(&INIT_SEQUENCE[step]); } -static constexpr uint8_t INIT_MAX_TIMEOUTS = 2; +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint8_t WRITE_SEQUENCE_LENGTH = sizeof(WRITE_SEQUENCE) / sizeof(WRITE_SEQUENCE[0]); +#endif +static constexpr uint8_t STEP_MAX_TIMEOUTS = 2; static constexpr uint32_t ACK_TIMEOUT_MS = 300; static constexpr uint32_t COMMAND_GAP_MS = 300; // pause after an acknowledgement before the next command static constexpr uint32_t REINIT_PAUSE_MS = 15000; // pause before starting over after the module stopped answering @@ -49,6 +78,20 @@ void LD2410SComponent::dump_config() { #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_); #endif +#ifdef USE_SENSOR + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "Distance", this->distance_sensor_); +#endif +#ifdef USE_LD2410S_VERSION + LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); +#endif +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + for (auto *number : this->numbers_) { + LOG_NUMBER(" ", "Setting", number); + } +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + LOG_SELECT(" ", "Response Speed", this->response_speed_select_); +#endif } void LD2410SComponent::loop() { @@ -67,35 +110,80 @@ void LD2410SComponent::loop() { avail -= len; } const uint32_t now = App.get_loop_component_start_time(); - this->run_init_sequence_(now); - if (this->init_step_ >= INIT_SEQUENCE_LENGTH && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { + if (this->sequence_ == nullptr) { + if (!this->init_done_) { + this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, now); +#ifdef USE_LD2410S_PARAMETERS + } else if (this->parameters_dirty_) { + this->parameters_dirty_ = false; + this->start_sequence_(WRITE_SEQUENCE, WRITE_SEQUENCE_LENGTH, this->next_send_at_); +#endif + } + } + this->run_sequence_(now); + if (this->init_done_ && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { // The count tells real silence apart from frames that never parse (wrong baud rate or firmware) ESP_LOGW(TAG, "No data for %" PRIu32 " s (%u frames dropped), starting over", LINK_TIMEOUT_MS / 1000, this->dropped_frames_); this->dropped_frames_ = 0; - this->status_set_warning(); - this->init_step_ = 0; - this->init_timeouts_ = 0; - this->awaiting_ack_ = false; - this->next_send_at_ = now; + this->restart_init_(now); } } -void LD2410SComponent::run_init_sequence_(uint32_t now) { - if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast(now - this->next_send_at_) < 0) { +#ifdef USE_LD2410S_PARAMETERS +bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) { + // All six are written together, so the other five must be the module's own values + if (!this->init_done_) { + ESP_LOGW(TAG, "Parameters not read yet, change ignored"); + return false; + } + if (!this->parameters_known_) { + ESP_LOGW(TAG, "The module reported a parameter out of range, changes are disabled"); + return false; + } + this->parameters_[static_cast(parameter)] = value; + this->parameters_dirty_ = true; // written by loop() once no sequence is running + return true; +} +#endif + +void LD2410SComponent::restart_init_(uint32_t at) { + this->init_done_ = false; + this->status_set_warning(); // cleared again when the new init sequence is acknowledged +#ifdef USE_LD2410S_PARAMETERS + // The module's own values count again once it is set up anew + if (this->parameters_dirty_) { + ESP_LOGW(TAG, "Dropping the pending parameter change, the module's values are read back"); + } + this->parameters_dirty_ = false; + this->parameters_known_ = false; // a refused value may sit in parameters_ until the read replaces it +#endif + this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, at); +} + +void LD2410SComponent::start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now) { + this->sequence_ = sequence; + this->sequence_len_ = length; + this->step_ = 0; + this->step_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now; +} + +uint16_t LD2410SComponent::current_command_() const { return progmem_read_uint16(&this->sequence_[this->step_]); } + +void LD2410SComponent::run_sequence_(uint32_t now) { + if (this->sequence_ == nullptr || static_cast(now - this->next_send_at_) < 0) { return; } - const uint16_t command = init_command(this->init_step_); + const uint16_t command = this->current_command_(); if (this->awaiting_ack_) { - if (++this->init_timeouts_ > INIT_MAX_TIMEOUTS) { + if (++this->step_timeouts_ > STEP_MAX_TIMEOUTS) { ESP_LOGW(TAG, "No acknowledgement for command %04X, starting over in %" PRIu32 " s", command, REINIT_PAUSE_MS / 1000); // Leave configuration mode whatever state the module is in, then begin again this->send_command_(CONFIG_MODE_END_CMD); - this->init_step_ = 0; - this->init_timeouts_ = 0; - this->awaiting_ack_ = false; - this->next_send_at_ = now + REINIT_PAUSE_MS; + this->restart_init_(now + REINIT_PAUSE_MS); return; } ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command); @@ -120,6 +208,22 @@ void LD2410SComponent::send_command_(uint16_t command) { frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF; frame[pos++] = CONFIG_MODE_START_VALUE >> 8; break; +#ifdef USE_LD2410S_PARAMETERS + case PARAMETERS_READ_CMD: + case PARAMETERS_WRITE_CMD: + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + const uint16_t key = progmem_read_uint16(&PARAMETER_KEYS[i]); + frame[pos++] = key & 0xFF; + frame[pos++] = key >> 8; + if (command == PARAMETERS_WRITE_CMD) { + const uint32_t value = this->parameters_[i]; // a 32-bit field on the wire + for (uint8_t shift = 0; shift < 32; shift += 8) { + frame[pos++] = (value >> shift) & 0xFF; + } + } + } + break; +#endif default: break; } @@ -252,27 +356,27 @@ bool LD2410SComponent::handle_frame_() { } bool LD2410SComponent::handle_data_frame_(const uint8_t *payload, uint16_t len) { - uint8_t state; - if (this->frame_type_ == FrameType::SHORT_DATA) { - // [state][distance low][distance high] - state = payload[0]; - } else if (len >= 4 && payload[0] == DATA_TYPE_STANDARD) { - // [type][state][distance low][distance high]...; the module only sends these before the init - // sequence switches it to minimal output - state = payload[1]; - } else { - if (len >= 1) { - ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + // Short frames carry [state][distance low][distance high]; standard frames put a type byte first, and the + // module only sends them before the init sequence switches it to minimal output + const uint8_t *data = payload; + if (this->frame_type_ == FrameType::STD_DATA) { + if (len < 4 || payload[0] != DATA_TYPE_STANDARD) { + if (len >= 1) { + ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + } + return true; } - return true; + data = payload + 1; } + const uint8_t state = data[0]; + const uint16_t distance = encode_uint16(data[2], data[1]); // States 0 and 1 mean no target, 2 and 3 a target; anything else is line noise that happened to // carry the header and footer bytes if (state > 3) { ESP_LOGV(TAG, "Dropping frame with impossible state %u", state); return false; } - this->publish_target_(state > 1); + this->publish_target_(state > 1, distance); return true; } @@ -283,32 +387,147 @@ void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len) } const uint16_t command_word = encode_uint16(payload[1], payload[0]); const uint16_t ack = encode_uint16(payload[3], payload[2]); - if (!this->awaiting_ack_ || command_word != (init_command(this->init_step_) | CMD_CONFIRMATION)) { + if (!this->awaiting_ack_) { ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); return; } - if (ack != 0) { - // Leave the command pending so the timeout resends it - ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", init_command(this->init_step_), ack); + const uint16_t command = this->current_command_(); + if (command_word != (command | CMD_CONFIRMATION)) { + ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); return; } +#ifdef USE_LD2410S_PARAMETERS + if (ack != 0 && command == PARAMETERS_WRITE_CMD) { + // Resending the same values would be rejected again; leave configuration mode and read the + // module's own values back, which republishes them + ESP_LOGW(TAG, "Module rejected the parameters, ack %04X", ack); + this->send_command_(CONFIG_MODE_END_CMD); + this->restart_init_(App.get_loop_component_start_time() + COMMAND_GAP_MS); + return; + } +#endif + if (ack != 0) { + // Leave the command pending so the timeout resends it + ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", command, ack); + return; + } + // A short answer leaves the read pending the same way, so init cannot finish without the values +#ifdef USE_LD2410S_PARAMETERS + if (command == PARAMETERS_READ_CMD && !this->handle_parameters_(&payload[4], len - 4)) + return; +#endif +#ifdef USE_LD2410S_VERSION + if (command == VERSION_READ_CMD && !this->handle_version_(&payload[4], len - 4)) + return; +#endif this->awaiting_ack_ = false; - this->init_timeouts_ = 0; - this->init_step_++; - this->next_send_at_ = App.get_loop_component_start_time() + COMMAND_GAP_MS; - if (this->init_step_ >= INIT_SEQUENCE_LENGTH) { + this->step_timeouts_ = 0; + const uint32_t now = App.get_loop_component_start_time(); + this->next_send_at_ = now + COMMAND_GAP_MS; + if (++this->step_ < this->sequence_len_) { + return; + } + const bool was_init = this->sequence_ == INIT_SEQUENCE; + this->sequence_ = nullptr; + if (was_init) { ESP_LOGD(TAG, "Setup done"); - this->last_frame_at_ = App.get_loop_component_start_time(); + this->init_done_ = true; + this->last_frame_at_ = now; this->status_clear_warning(); } } -void LD2410SComponent::publish_target_(bool target) { +#ifdef USE_LD2410S_PARAMETERS +// The answer lists the parameter values in key order, 32 bits each; returns false when it is cut short +bool LD2410SComponent::handle_parameters_(const uint8_t *values, uint16_t len) { + if (len < PARAMETER_COUNT * PARAMETER_VALUE_SIZE) { + ESP_LOGW(TAG, "Parameter answer too short (%u bytes)", len); + return false; + } + // Every parameter must fit the low byte of its little-endian value; otherwise nothing is stored or + // published and writes stay refused, so a cut-down value is never shown or sent back + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + const uint8_t *v = &values[i * PARAMETER_VALUE_SIZE]; + if ((v[1] | v[2] | v[3]) != 0) { + ESP_LOGW(TAG, "Parameter %u out of range: %" PRIu32 ", changes stay disabled", i, + encode_uint32(v[3], v[2], v[1], v[0])); + this->parameters_known_ = false; +#ifdef USE_NUMBER + // A value a number showed before the module answered is not confirmed + for (auto *number : this->numbers_) { + if (number != nullptr) { + number->publish_state(NAN); + } + } +#endif + return true; + } + } + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + this->parameters_[i] = values[i * PARAMETER_VALUE_SIZE]; + } + this->parameters_known_ = true; + this->publish_parameters_(); + ESP_LOGD(TAG, "Parameters: gates %u to %u, no delay %u s, frequencies %u/%u dHz, response speed %u", + this->parameters_[1], this->parameters_[0], this->parameters_[2], this->parameters_[3], this->parameters_[4], + this->parameters_[5]); + return true; +} +#endif + +#ifdef USE_LD2410S_VERSION +// Returns false when the answer is cut short +bool LD2410SComponent::handle_version_(const uint8_t *values, uint16_t len) { + if (len < VERSION_SIZE) { + ESP_LOGW(TAG, "Version answer too short (%u bytes)", len); + return false; + } + const uint8_t *v = &values[VERSION_TYPE_SIZE]; + char version[18]; + snprintf(version, sizeof(version), "%u.%u.%u", encode_uint16(v[1], v[0]), encode_uint16(v[3], v[2]), + encode_uint16(v[5], v[4])); + ESP_LOGD(TAG, "Firmware %s", version); + if (this->version_text_sensor_ != nullptr) { + this->version_text_sensor_->publish_state(version); + } + return true; +} +#endif + +void LD2410SComponent::publish_target_(bool target, uint16_t distance) { #ifdef USE_BINARY_SENSOR if (this->target_binary_sensor_ != nullptr) { this->target_binary_sensor_->publish_state(target); } #endif +#ifdef USE_SENSOR + // The module keeps reporting the last distance after the target left + SAFE_PUBLISH_SENSOR(this->distance_sensor_, target ? distance : 0); +#endif } +#ifdef USE_LD2410S_PARAMETERS +void LD2410SComponent::publish_parameters_() { +#ifdef USE_NUMBER + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + if (this->numbers_[i] != nullptr) { + this->numbers_[i]->publish_raw(this->parameters_[i]); + } + } +#endif +#ifdef USE_SELECT + if (this->response_speed_select_ != nullptr) { + const uint8_t speed = this->parameters_[static_cast(Parameter::PARAMETER_RESPONSE_SPEED)]; + for (uint8_t i = 0; i < RESPONSE_SPEED_COUNT; i++) { + if (progmem_read_byte(&RESPONSE_SPEEDS[i]) == speed) { + this->response_speed_select_->publish_state(i); + return; + } + } + ESP_LOGV(TAG, "Unknown response speed %u", speed); + } +#endif +} +#endif + } // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s.h b/esphome/components/ld2410s/ld2410s.h index 4af809836c..9a0a256974 100644 --- a/esphome/components/ld2410s/ld2410s.h +++ b/esphome/components/ld2410s/ld2410s.h @@ -2,10 +2,21 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/ld24xx/ld24xx.h" +#endif +#ifdef USE_LD2410S_VERSION +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) +#include "esphome/components/select/select.h" +#endif namespace esphome::ld2410s { @@ -13,16 +24,55 @@ namespace esphome::ld2410s { static constexpr size_t RX_BUFFER_SIZE = 96; static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte"); +/// The module's common parameters, in the order the parameter read answers them +enum class Parameter : uint8_t { + PARAMETER_MAX_DISTANCE, + PARAMETER_MIN_DISTANCE, + PARAMETER_NO_DELAY, + PARAMETER_STATUS_FREQUENCY, + PARAMETER_DISTANCE_FREQUENCY, + PARAMETER_RESPONSE_SPEED, + PARAMETER_COUNT, +}; +static constexpr uint8_t PARAMETER_COUNT = static_cast(Parameter::PARAMETER_COUNT); +// Response speed values, in the order of the select's options (Normal, Fast) +static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10}; +static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS); + +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) +class LD2410SNumber; +#endif + class LD2410SComponent final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(target) #endif +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(distance, uint16_t) +#endif +#ifdef USE_LD2410S_VERSION + SUB_TEXT_SENSOR(version) +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + SUB_SELECT(response_speed) +#endif public: void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence void loop() override; void dump_config() override; + /// Store a parameter in the module's units; loop() then writes all of them to the module. + /// Returns false, and changes nothing, until the module's own values have been read. +#ifdef USE_LD2410S_PARAMETERS + bool set_parameter(Parameter parameter, uint8_t value); +#ifdef USE_NUMBER + void set_number(Parameter parameter, LD2410SNumber *number) { + this->numbers_[static_cast(parameter)] = number; + } +#endif +#endif + protected: enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND }; @@ -32,6 +82,12 @@ class LD2410SComponent final : public Component, public uart::UARTDevice { /// Returns false for a data frame whose state byte is impossible, so it counts as dropped. bool handle_data_frame_(const uint8_t *payload, uint16_t len); void handle_command_ack_(const uint8_t *payload, uint16_t len); +#ifdef USE_LD2410S_PARAMETERS + bool handle_parameters_(const uint8_t *values, uint16_t len); +#endif +#ifdef USE_LD2410S_VERSION + bool handle_version_(const uint8_t *values, uint16_t len); +#endif void count_dropped_frame_() { if (this->dropped_frames_ < UINT8_MAX) { this->dropped_frames_++; @@ -42,21 +98,41 @@ class LD2410SComponent final : public Component, public uart::UARTDevice { this->expected_len_ = 0; this->frame_type_ = FrameType::NONE; } - void run_init_sequence_(uint32_t now); + void start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now); + void restart_init_(uint32_t at); + void run_sequence_(uint32_t now); + uint16_t current_command_() const; void send_command_(uint16_t command); - void publish_target_(bool target); + void publish_target_(bool target, uint16_t distance); +#ifdef USE_LD2410S_PARAMETERS + void publish_parameters_(); +#endif +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number +#endif + + const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent uint32_t next_send_at_{0}; uint32_t last_frame_at_{0}; +#ifdef USE_LD2410S_PARAMETERS + uint8_t parameters_[PARAMETER_COUNT]{}; // all fit a byte: gates, seconds, tenths of a hertz, speed +#endif uint8_t rx_buffer_[RX_BUFFER_SIZE]; uint8_t rx_len_{0}; uint8_t expected_len_{0}; - uint8_t init_step_{0}; - uint8_t init_timeouts_{0}; + uint8_t sequence_len_{0}; + uint8_t step_{0}; + uint8_t step_timeouts_{0}; uint8_t dropped_frames_{0}; // since the last accepted data frame, saturates at 255 FrameType frame_type_{FrameType::NONE}; bool awaiting_ack_{false}; bool rescanning_{false}; + bool init_done_{false}; +#ifdef USE_LD2410S_PARAMETERS + bool parameters_known_{false}; // the module answered the parameter read at least once + bool parameters_dirty_{false}; +#endif }; } // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s_number.h b/esphome/components/ld2410s/ld2410s_number.h new file mode 100644 index 0000000000..bc24e300ca --- /dev/null +++ b/esphome/components/ld2410s/ld2410s_number.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + +#include "ld2410s.h" +#include "esphome/components/number/number.h" + +namespace esphome::ld2410s { + +/// Edits one common parameter; scale converts the module's units to the number's. +class LD2410SNumber final : public number::Number, public Parented { + public: + LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {} + + void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); } + + protected: + void control(float value) override { + // Show the step the module got, not a value between two steps + const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte + if (this->parent_->set_parameter(this->parameter_, raw)) + this->publish_raw(raw); + } + + const Parameter parameter_; + const float scale_; +}; + +} // namespace esphome::ld2410s + +#endif // USE_NUMBER && USE_LD2410S_PARAMETERS diff --git a/esphome/components/ld2410s/ld2410s_select.h b/esphome/components/ld2410s/ld2410s_select.h new file mode 100644 index 0000000000..e2598b917a --- /dev/null +++ b/esphome/components/ld2410s/ld2410s_select.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + +#include "ld2410s.h" +#include "esphome/components/select/select.h" + +namespace esphome::ld2410s { + +class LD2410SSelect final : public select::Select, public Parented { + protected: + void control(size_t index) override { + if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index]))) + this->publish_state(index); + } +}; + +} // namespace esphome::ld2410s + +#endif // USE_SELECT && USE_LD2410S_PARAMETERS diff --git a/esphome/components/ld2410s/number.py b/esphome/components/ld2410s/number.py new file mode 100644 index 0000000000..912e139734 --- /dev/null +++ b/esphome/components/ld2410s/number.py @@ -0,0 +1,108 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_FREQUENCY, + ENTITY_CATEGORY_CONFIG, + ICON_PULSE, + ICON_TIMELAPSE, + UNIT_HERTZ, + UNIT_METER, + UNIT_SECOND, +) +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns + +DEPENDENCIES = ["ld2410s"] + +CONF_DISTANCE_REPORTING_FREQUENCY = "distance_reporting_frequency" +CONF_MAX_DISTANCE = "max_distance" +CONF_MIN_DISTANCE = "min_distance" +CONF_NO_DELAY = "no_delay" +CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency" + +LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number) + +# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz +GATE_WIDTH_M = 0.7 +FREQUENCY_SCALE = 0.1 +_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M} +_FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5} + + +def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema: + return number.number_schema( + LD2410SNumber, + device_class=device_class, + entity_category=ENTITY_CATEGORY_CONFIG, + unit_of_measurement=unit, + icon=icon, + ) + + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Optional(CONF_MAX_DISTANCE): _number_schema( + DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-right" + ), + cv.Optional(CONF_MIN_DISTANCE): _number_schema( + DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-left" + ), + cv.Optional(CONF_NO_DELAY): _number_schema( + DEVICE_CLASS_DURATION, UNIT_SECOND, ICON_TIMELAPSE + ), + cv.Optional(CONF_STATUS_REPORTING_FREQUENCY): _number_schema( + DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE + ), + cv.Optional(CONF_DISTANCE_REPORTING_FREQUENCY): _number_schema( + DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_PARAMETERS") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_MAX_DISTANCE, + partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE), + Parameter.PARAMETER_MAX_DISTANCE, + GATE_WIDTH_M, + **_DISTANCE_RANGE, + ) + await numbers( + CONF_MIN_DISTANCE, + partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE), + Parameter.PARAMETER_MIN_DISTANCE, + GATE_WIDTH_M, + **_DISTANCE_RANGE, + ) + await numbers( + CONF_NO_DELAY, + partial(hub.set_number, Parameter.PARAMETER_NO_DELAY), + Parameter.PARAMETER_NO_DELAY, + 1, + min_value=1, + max_value=120, + step=1, + ) + await numbers( + CONF_STATUS_REPORTING_FREQUENCY, + partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY), + Parameter.PARAMETER_STATUS_FREQUENCY, + FREQUENCY_SCALE, + **_FREQUENCY_RANGE, + ) + await numbers( + CONF_DISTANCE_REPORTING_FREQUENCY, + partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY), + Parameter.PARAMETER_DISTANCE_FREQUENCY, + FREQUENCY_SCALE, + **_FREQUENCY_RANGE, + ) diff --git a/esphome/components/ld2410s/select.py b/esphome/components/ld2410s/select.py new file mode 100644 index 0000000000..4a1376b9bf --- /dev/null +++ b/esphome/components/ld2410s/select.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns + +DEPENDENCIES = ["ld2410s"] + +CONF_RESPONSE_SPEED = "response_speed" + +LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select) + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_RESPONSE_SPEED): select.select_schema( + LD2410SSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon="mdi:speedometer", + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_PARAMETERS") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"] + ) diff --git a/esphome/components/ld2410s/sensor.py b/esphome/components/ld2410s/sensor.py new file mode 100644 index 0000000000..96eccc08f4 --- /dev/null +++ b/esphome/components/ld2410s/sensor.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DISTANCE, + DEVICE_CLASS_DISTANCE, + ICON_SIGNAL_DISTANCE_VARIANT, + STATE_CLASS_MEASUREMENT, + UNIT_CENTIMETER, +) +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_DISTANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_CENTIMETER, + icon=ICON_SIGNAL_DISTANCE_VARIANT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) diff --git a/esphome/components/ld2410s/text_sensor.py b/esphome/components/ld2410s/text_sensor.py new file mode 100644 index 0000000000..d017d3f1a6 --- /dev/null +++ b/esphome/components/ld2410s/text_sensor.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_VERSION") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 072abe8374..1a5ab82d93 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -97,6 +97,8 @@ #define USE_JSON #define USE_JSON_ARENA #define USE_RADIO_FREQUENCY +#define USE_LD2410S_PARAMETERS +#define USE_LD2410S_VERSION #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT diff --git a/tests/components/ld2410s/common-bare.yaml b/tests/components/ld2410s/common-bare.yaml new file mode 100644 index 0000000000..2f0e569bb9 --- /dev/null +++ b/tests/components/ld2410s/common-bare.yaml @@ -0,0 +1,28 @@ +ld2410s: + id: ld2410s_bare + uart_id: uart_bus + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_bare + has_target: + name: Has Target + +# Entities of other platforms must not pull the parameter code into a bare hub +number: + - platform: template + name: Unrelated Number + optimistic: true + min_value: 0 + max_value: 10 + step: 1 + +select: + - platform: template + name: Unrelated Select + optimistic: true + options: ["a", "b"] + +text_sensor: + - platform: template + name: Unrelated Text diff --git a/tests/components/ld2410s/common.yaml b/tests/components/ld2410s/common.yaml index 33b213d363..5ae9649710 100644 --- a/tests/components/ld2410s/common.yaml +++ b/tests/components/ld2410s/common.yaml @@ -5,3 +5,31 @@ binary_sensor: - platform: ld2410s has_target: name: Presence + +sensor: + - platform: ld2410s + distance: + name: Distance + +text_sensor: + - platform: ld2410s + version: + name: Firmware + +number: + - platform: ld2410s + max_distance: + name: Max Distance + min_distance: + name: Min Distance + no_delay: + name: No Delay + status_reporting_frequency: + name: Status Reporting Frequency + distance_reporting_frequency: + name: Distance Reporting Frequency + +select: + - platform: ld2410s + response_speed: + name: Response Speed diff --git a/tests/components/ld2410s/test-bare.esp8266-ard.yaml b/tests/components/ld2410s/test-bare.esp8266-ard.yaml new file mode 100644 index 0000000000..bda2f21bb4 --- /dev/null +++ b/tests/components/ld2410s/test-bare.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld2410s: !include common-bare.yaml diff --git a/tests/integration/fixtures/uart_mock_ld2410s.yaml b/tests/integration/fixtures/uart_mock_ld2410s.yaml index 113e0881e6..858a689b39 100644 --- a/tests/integration/fixtures/uart_mock_ld2410s.yaml +++ b/tests/integration/fixtures/uart_mock_ld2410s.yaml @@ -22,16 +22,58 @@ uart_mock: id: mock_uart baud_rate: 115200 auto_start: false + on_tx: + - then: + - lambda: |- + // Parameter read: the first answer stops after one value so the read is resent after the + // 300 ms timeout; the resend gets gates 0 to 16, no delay 10 s, 8 Hz reporting, normal speed + static const std::vector PARAMETERS_READ = { + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x71, 0x00, 0x05, 0x00, 0x0A, 0x00, + 0x06, 0x00, 0x02, 0x00, 0x0C, 0x00, 0x0B, 0x00, 0x04, 0x03, 0x02, 0x01}; + static int reads = 0; + if (data != PARAMETERS_READ) { + return; + } + if (++reads == 1) { + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01}); + return; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x50, 0x00, + 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01}); responses: # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Version read, answered with device type 1 and firmware 1.2.3 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + # The parameter read is answered by the on_tx lambda below: cut short once, then in full # Output-mode switch to minimal, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] # Config-mode end, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + # Setting the max distance to 7 m writes all six parameters inside a configuration-mode session + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + - expect_tx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x26, 0x00, 0x70, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x0A, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x50, 0x00, 0x00, 0x00, + 0x0C, 0x00, 0x50, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x05, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] injections: # Short frame, state 2 (target), distance 300 -> presence on - delay: 100ms @@ -76,6 +118,26 @@ binary_sensor: has_target: name: "Has Target" +sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + distance: + name: "Distance" + +text_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + version: + name: "Version" + +number: + - platform: ld2410s + ld2410s_id: ld2410s_dev + max_distance: + name: "Max Distance" + no_delay: + name: "No Delay" + button: - platform: template name: "Start Scenario" diff --git a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml index 5f06985c7b..57b66f0d9d 100644 --- a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml +++ b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml @@ -26,6 +26,25 @@ uart_mock: # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Version read, answered with device type 1 and firmware 1.2.3 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + # Parameter read, answered with gates 0 to 16, no delay 10 s, 8 Hz reporting, normal speed + - expect_tx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x71, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x06, 0x00, 0x02, 0x00, + 0x0C, 0x00, 0x0B, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x05, 0x00, + 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, + ] # Output-mode switch to minimal, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] diff --git a/tests/integration/test_uart_mock_ld2410s.py b/tests/integration/test_uart_mock_ld2410s.py index 8cd9b4b8dd..44b808cd4c 100644 --- a/tests/integration/test_uart_mock_ld2410s.py +++ b/tests/integration/test_uart_mock_ld2410s.py @@ -1,9 +1,13 @@ """Integration tests for the LD2410S component with mock UART. test_uart_mock_ld2410s: - 1. Init sequence - config-mode start, output-mode switch and config-mode end go - out in order, each after the previous one is acknowledged, then the - component logs "Setup done" and clears its warning + 1. Init sequence - config-mode start, version read, parameter read, output-mode + switch and config-mode end go out in order, each after the previous one is + acknowledged, then the component logs "Setup done" and clears its warning; + the version and parameter answers are parsed and logged + 1b. Setting the max distance number to 7 m writes all six parameters (gate 10 + plus the values read at init) inside a configuration-mode session, and the + number reports 7 m 2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard data frame 3. Resync - a noise byte or a partial header directly before a short frame @@ -34,15 +38,40 @@ from __future__ import annotations import asyncio import re -from aioesphomeapi import APIClient, ButtonInfo +from aioesphomeapi import APIClient, ButtonInfo, EntityState, NumberInfo, NumberState import pytest from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction CONFIG_START_TX = "FD:FC:FB:FA:04:00:FF:00:01:00:04:03:02:01" +VERSION_READ_TX = "FD:FC:FB:FA:02:00:00:00:04:03:02:01" +PARAMETERS_READ_TX = ( + "FD:FC:FB:FA:0E:00:71:00:05:00:0A:00:06:00:02:00:0C:00:0B:00:04:03:02:01" +) OUTPUT_MODE_TX = "FD:FC:FB:FA:08:00:7A:00:00:00:00:00:00:00:04:03:02:01" CONFIG_END_TX = "FD:FC:FB:FA:02:00:FE:00:04:03:02:01" +INIT_TX = [ + CONFIG_START_TX, + VERSION_READ_TX, + # The fixture cuts the first answer short, so the read goes out again + PARAMETERS_READ_TX, + PARAMETERS_READ_TX, + OUTPUT_MODE_TX, + CONFIG_END_TX, +] +# All six parameters: max gate 10, min gate 0, no delay 10, both frequencies 80, speed 5 +PARAMETERS_WRITE_TX = ( + "FD:FC:FB:FA:26:00:70:00:05:00:0A:00:00:00:0A:00:00:00:00:00:06:00:0A:00:00:00:02:00:50:00:00:00:" + "0C:00:50:00:00:00:0B:00:05:00:00:00:04:03:02:01" +) +WRITE_TX = [CONFIG_START_TX, PARAMETERS_WRITE_TX, CONFIG_END_TX] +# Logged once the version and parameter answers are parsed +PARSED_ANSWERS = [ + "Firmware 1.2.3", + "Parameter answer too short (4 bytes)", + "Parameters: gates 0 to 16, no delay 10 s", +] ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") @@ -119,12 +148,17 @@ async def test_uart_mock_ld2410s( setup_done = loop.create_future() warning_cleared = loop.create_future() dropped_states: list[str] = [] + parsed_answers: list[str] = [] + write_done = loop.create_future() def line_callback(line: str) -> None: if (frame := _tx_frame(line)) is not None: tx_frames.append(frame) + if len(tx_frames) >= 9 and not write_done.done(): + write_done.set_result(True) if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): setup_done.set_result(True) + parsed_answers.extend(answer for answer in PARSED_ANSWERS if answer in line) if "ld2410s cleared Warning flag" in line and not warning_cleared.done(): warning_cleared.set_result(True) if "Dropping frame with impossible state " in line: @@ -133,8 +167,15 @@ async def test_uart_mock_ld2410s( ) collector = SensorStateCollector( - sensor_names=[], binary_sensor_names=["has_target"] + sensor_names=["distance"], binary_sensor_names=["has_target"] ) + number_states: list[float] = [] + + def on_state(state: EntityState) -> None: + collector.on_state(state) + if isinstance(state, NumberState) and state.key == max_distance_key: + number_states.append(state.state) + all_presence = collector.add_waiter( lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE) ) @@ -148,17 +189,41 @@ async def test_uart_mock_ld2410s( except TimeoutError: pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") - assert tx_frames[:3] == [CONFIG_START_TX, OUTPUT_MODE_TX, CONFIG_END_TX] + assert tx_frames[:6] == INIT_TX + assert parsed_answers == PARSED_ANSWERS - await _start_scenario(client, collector) + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + max_distance_key = require_entity(entities, "max_distance", NumberInfo).key + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + # The values read from the module: gate 16 and 10 s + no_delay_key = require_entity(entities, "no_delay", NumberInfo).key + initial = initial_state_helper.initial_states + assert initial[max_distance_key].state == pytest.approx(11.2) + assert initial[no_delay_key].state == pytest.approx(10) + + client.number_command(max_distance_key, 7.0) + try: + await asyncio.wait_for(write_done, 5.0) + except TimeoutError: + pytest.fail(f"Parameter write not sent. TX frames: {tx_frames}") + assert tx_frames[6:9] == WRITE_TX, tx_frames + assert number_states == [pytest.approx(7.0)] + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) await _wait_for_presence(collector, all_presence) # Let any unexpected extra update arrive before comparing await asyncio.sleep(0.3) assert collector.binary_states["has_target"] == EXPECTED_PRESENCE assert dropped_states == EXPECTED_DROPPED_STATES + # A target 300 cm away, then none: the distance follows the presence + assert collector.sensor_states["distance"][:2] == [300, 0] # Data frames must not restart the init sequence - assert len(tx_frames) == 3, tx_frames + assert len(tx_frames) == 9, tx_frames @pytest.mark.asyncio From 74e96b7da6b3986ea9d20a8b2338f9ca836ea405 Mon Sep 17 00:00:00 2001 From: rwalker777 <49888088+rwalker777@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:21:00 -0500 Subject: [PATCH 264/268] [INA3221] Add optional flags to expose all functionality (#16309) 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/ina3221/ina3221.cpp | 136 ++++++++--- esphome/components/ina3221/ina3221.h | 25 +- esphome/components/ina3221/sensor.py | 224 +++++++++++++++--- esphome/core/defines.h | 2 + tests/components/ina3221/common.yaml | 29 +++ tests/components/ina3221/test.esp32-idf.yaml | 3 +- .../components/ina3221/test.esp8266-ard.yaml | 3 +- tests/components/ina3221/test.rp2040-ard.yaml | 3 +- 8 files changed, 352 insertions(+), 73 deletions(-) diff --git a/esphome/components/ina3221/ina3221.cpp b/esphome/components/ina3221/ina3221.cpp index f3647c4d1c..e66e245676 100644 --- a/esphome/components/ina3221/ina3221.cpp +++ b/esphome/components/ina3221/ina3221.cpp @@ -13,6 +13,24 @@ static const uint8_t INA3221_REGISTER_CHANNEL2_SHUNT_VOLTAGE = 0x03; static const uint8_t INA3221_REGISTER_CHANNEL2_BUS_VOLTAGE = 0x04; static const uint8_t INA3221_REGISTER_CHANNEL3_SHUNT_VOLTAGE = 0x05; static const uint8_t INA3221_REGISTER_CHANNEL3_BUS_VOLTAGE = 0x06; +#ifdef USE_INA3221_ALERT_LIMITS +static constexpr uint8_t INA3221_REGISTER_CHANNEL1_CRITICAL_ALERT = 0x07; +static constexpr uint8_t INA3221_REGISTER_CHANNEL1_WARNING_ALERT = 0x0A; +#endif +#ifdef USE_INA3221_SUMMATION +static constexpr uint8_t INA3221_REGISTER_SHUNT_VOLTAGE_SUM = 0x0D; +static constexpr uint8_t INA3221_REGISTER_MASK_ENABLE = 0x0F; +#endif + +// Configuration register: bit 15 resets the chip, bits 14..12 enable channels 1..3 (the same bit +// positions select channels for summation in the mask/enable register) and bits 2..0 set the mode. +// Code generation fills in the averaging and conversion time fields. +static constexpr uint16_t INA3221_CONFIG_RESET = 0x8000; +static constexpr uint16_t INA3221_CONFIG_CHANNEL1_ENABLE = 0x4000; +static constexpr uint16_t INA3221_CONFIG_CHANNEL_MASK = 0x7000; +static constexpr uint16_t INA3221_CONFIG_MODE_MASK = 0x0007; + +static constexpr uint32_t READ_TIMEOUT_ID = 0; // Addresses: // A0 = GND -> 0x40 @@ -21,37 +39,38 @@ static const uint8_t INA3221_REGISTER_CHANNEL3_BUS_VOLTAGE = 0x06; // A0 = SCL -> 0x43 void INA3221Component::setup() { - // Config Register - // 0bx000000000000000 << 15 RESET Bit (1 -> trigger reset) - if (!this->write_byte_16(INA3221_REGISTER_CONFIG, 0x8000)) { + if (!this->write_byte_16(INA3221_REGISTER_CONFIG, INA3221_CONFIG_RESET)) { this->mark_failed(); return; } delay(1); - uint16_t config = 0; - // 0b0xxx000000000000 << 12 Channel Enables (1 -> ON) - if (this->channels_[0].exists()) { - config |= 0b0100000000000000; - } - if (this->channels_[1].exists()) { - config |= 0b0010000000000000; - } - if (this->channels_[2].exists()) { - config |= 0b0001000000000000; - } - // 0b0000xxx000000000 << 9 Averaging Mode (0 -> 1 sample, 111 -> 1024 samples) - config |= 0b0000000000000000; - // 0b0000000xxx000000 << 6 Bus Voltage Conversion time (100 -> 1.1ms, 111 -> 8.244 ms) - config |= 0b0000000111000000; - // 0b0000000000xxx000 << 3 Shunt Voltage Conversion time (same as above) - config |= 0b0000000000111000; - // 0b0000000000000xxx << 0 Operating mode (111 -> Shunt and bus, continuous) - config |= 0b0000000000000111; - if (!this->write_byte_16(INA3221_REGISTER_CONFIG, config)) { + if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) { this->mark_failed(); return; } + +#ifdef USE_INA3221_ALERT_LIMITS + for (uint8_t i = 0; i < 3; i++) { + const INA3221Channel &channel = this->channels_[i]; + if ((channel.critical_limit_ != 0 && + !this->write_byte_16(INA3221_REGISTER_CHANNEL1_CRITICAL_ALERT + i, channel.critical_limit_)) || + (channel.warning_limit_ != 0 && + !this->write_byte_16(INA3221_REGISTER_CHANNEL1_WARNING_ALERT + i, channel.warning_limit_))) { + this->mark_failed(); + return; + } + } +#endif + +#ifdef USE_INA3221_SUMMATION + // Sum the shunt voltages of every enabled channel + if (this->sum_shunt_voltage_sensor_ != nullptr && + !this->write_byte_16(INA3221_REGISTER_MASK_ENABLE, this->config_ & INA3221_CONFIG_CHANNEL_MASK)) { + this->mark_failed(); + return; + } +#endif } void INA3221Component::dump_config() { @@ -61,6 +80,10 @@ void INA3221Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Config register: 0x%04X", this->config_); + if (this->single_shot_wait_ms_ != 0) { + ESP_LOGCONFIG(TAG, " Single-shot conversion: %u ms", this->single_shot_wait_ms_); + } LOG_SENSOR(" ", "Bus Voltage #1", this->channels_[0].bus_voltage_sensor_); LOG_SENSOR(" ", "Shunt Voltage #1", this->channels_[0].shunt_voltage_sensor_); @@ -74,6 +97,11 @@ void INA3221Component::dump_config() { LOG_SENSOR(" ", "Shunt Voltage #3", this->channels_[2].shunt_voltage_sensor_); LOG_SENSOR(" ", "Current #3", this->channels_[2].current_sensor_); LOG_SENSOR(" ", "Power #3", this->channels_[2].power_sensor_); +#ifdef USE_INA3221_SUMMATION + LOG_SENSOR(" ", "Sum Shunt Voltage", this->sum_shunt_voltage_sensor_); + LOG_SENSOR(" ", "Sum Current", this->sum_current_sensor_); + LOG_SENSOR(" ", "Sum Power", this->sum_power_sensor_); +#endif } inline uint8_t ina3221_bus_voltage_register(int channel) { return 0x02 + channel * 2; } @@ -81,11 +109,36 @@ inline uint8_t ina3221_bus_voltage_register(int channel) { return 0x02 + channel inline uint8_t ina3221_shunt_voltage_register(int channel) { return 0x01 + channel * 2; } void INA3221Component::update() { + if (this->single_shot_wait_ms_ == 0) { + this->read_data_(); + return; + } + // In single-shot mode, writing the configuration register starts one conversion of every channel + if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) { + this->status_set_warning(); + return; + } + this->set_timeout(READ_TIMEOUT_ID, this->single_shot_wait_ms_, [this]() { this->read_data_(); }); +} + +void INA3221Component::read_data_() { +#ifdef USE_INA3221_SUMMATION + // The software sums need every enabled channel's readings, not only the ones with their own sensors + const bool sum_all = this->sum_current_sensor_ != nullptr || this->sum_power_sensor_ != nullptr; + float total_current_a = 0.0f; + float total_power_w = 0.0f; +#else + constexpr bool sum_all = false; +#endif + for (int i = 0; i < 3; i++) { + if ((this->config_ & (INA3221_CONFIG_CHANNEL1_ENABLE >> i)) == 0) { + continue; + } INA3221Channel &channel = this->channels_[i]; float bus_voltage_v = NAN, current_a = NAN; uint16_t raw; - if (channel.should_measure_bus_voltage()) { + if (sum_all || channel.should_measure_bus_voltage()) { if (!this->read_byte_16(ina3221_bus_voltage_register(i), &raw)) { this->status_set_warning(); return; @@ -94,7 +147,7 @@ void INA3221Component::update() { if (channel.bus_voltage_sensor_ != nullptr) channel.bus_voltage_sensor_->publish_state(bus_voltage_v); } - if (channel.should_measure_shunt_voltage()) { + if (sum_all || channel.should_measure_shunt_voltage()) { if (!this->read_byte_16(ina3221_shunt_voltage_register(i), &raw)) { this->status_set_warning(); return; @@ -109,17 +162,42 @@ void INA3221Component::update() { if (channel.power_sensor_ != nullptr) { channel.power_sensor_->publish_state(bus_voltage_v * current_a); } +#ifdef USE_INA3221_SUMMATION + if (sum_all) { + total_current_a += current_a; + total_power_w += bus_voltage_v * current_a; + } +#endif } + +#ifdef USE_INA3221_SUMMATION + if (this->sum_shunt_voltage_sensor_ != nullptr) { + uint16_t raw; + if (!this->read_byte_16(INA3221_REGISTER_SHUNT_VOLTAGE_SUM, &raw)) { + this->status_set_warning(); + return; + } + // 40 uV per step, held in bits 15..1 + this->sum_shunt_voltage_sensor_->publish_state(int16_t(raw) * 40.0f / 2.0f / 1000000.0f); + } + if (this->sum_current_sensor_ != nullptr) { + this->sum_current_sensor_->publish_state(total_current_a); + } + if (this->sum_power_sensor_ != nullptr) { + this->sum_power_sensor_->publish_state(total_power_w); + } +#endif +} + +void INA3221Component::on_powerdown() { + // Mode 0 powers the chip down; setup() resets it on the next boot + this->write_byte_16(INA3221_REGISTER_CONFIG, static_cast(this->config_ & ~INA3221_CONFIG_MODE_MASK)); } void INA3221Component::set_shunt_resistance(int channel, float resistance_ohm) { this->channels_[channel].shunt_resistance_ = resistance_ohm; } -bool INA3221Component::INA3221Channel::exists() { - return this->bus_voltage_sensor_ != nullptr || this->shunt_voltage_sensor_ != nullptr || - this->current_sensor_ != nullptr || this->power_sensor_ != nullptr; -} bool INA3221Component::INA3221Channel::should_measure_shunt_voltage() { return this->shunt_voltage_sensor_ != nullptr || this->current_sensor_ != nullptr || this->power_sensor_ != nullptr; } diff --git a/esphome/components/ina3221/ina3221.h b/esphome/components/ina3221/ina3221.h index 48226c743a..759dd6c13d 100644 --- a/esphome/components/ina3221/ina3221.h +++ b/esphome/components/ina3221/ina3221.h @@ -7,10 +7,17 @@ namespace esphome::ina3221 { class INA3221Component final : public PollingComponent, public i2c::I2CDevice { +#ifdef USE_INA3221_SUMMATION + SUB_SENSOR(sum_shunt_voltage) + SUB_SENSOR(sum_current) + SUB_SENSOR(sum_power) +#endif + public: void setup() override; void dump_config() override; void update() override; + void on_powerdown() override; void set_bus_voltage_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].bus_voltage_sensor_ = obj; } void set_shunt_voltage_sensor(int channel, sensor::Sensor *obj) { @@ -19,19 +26,35 @@ class INA3221Component final : public PollingComponent, public i2c::I2CDevice { void set_current_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].current_sensor_ = obj; } void set_power_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].power_sensor_ = obj; } void set_shunt_resistance(int channel, float resistance_ohm); + /// Configuration register value built by code generation: channel enables, averaging, conversion times, mode + void set_config_register(uint16_t config) { this->config_ = config; } + /// Time one single-shot conversion of every enabled channel takes; zero means continuous mode + void set_single_shot_wait_ms(uint16_t wait_ms) { this->single_shot_wait_ms_ = wait_ms; } +#ifdef USE_INA3221_ALERT_LIMITS + void set_warning_limit(int channel, uint16_t reg) { this->channels_[channel].warning_limit_ = reg; } + void set_critical_limit(int channel, uint16_t reg) { this->channels_[channel].critical_limit_ = reg; } +#endif protected: + void read_data_(); + struct INA3221Channel { float shunt_resistance_{0.1f}; sensor::Sensor *bus_voltage_sensor_{nullptr}; sensor::Sensor *shunt_voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; +#ifdef USE_INA3221_ALERT_LIMITS + // Alert limit register values; zero keeps the chip default, which never trips + uint16_t warning_limit_{0}; + uint16_t critical_limit_{0}; +#endif - bool exists(); bool should_measure_shunt_voltage(); bool should_measure_bus_voltage(); } channels_[3]; + uint16_t config_{0}; + uint16_t single_shot_wait_ms_{0}; }; } // namespace esphome::ina3221 diff --git a/esphome/components/ina3221/sensor.py b/esphome/components/ina3221/sensor.py index db8dad2f54..e872b73b09 100644 --- a/esphome/components/ina3221/sensor.py +++ b/esphome/components/ina3221/sensor.py @@ -1,5 +1,8 @@ +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, @@ -8,6 +11,7 @@ from esphome.const import ( CONF_POWER, CONF_SHUNT_RESISTANCE, CONF_SHUNT_VOLTAGE, + CONF_UPDATE_INTERVAL, DEVICE_CLASS_CURRENT, DEVICE_CLASS_POWER, DEVICE_CLASS_VOLTAGE, @@ -23,69 +27,195 @@ DEPENDENCIES = ["i2c"] CONF_CHANNEL_1 = "channel_1" CONF_CHANNEL_2 = "channel_2" CONF_CHANNEL_3 = "channel_3" +CONF_CONTINUOUS_MODE = "continuous_mode" +CONF_WARNING_CURRENT_LIMIT = "warning_current_limit" +CONF_CRITICAL_CURRENT_LIMIT = "critical_current_limit" +CONF_SUM_SHUNT_VOLTAGE = "sum_shunt_voltage" +CONF_SUM_CURRENT = "sum_current" +CONF_SUM_POWER = "sum_power" + +CHANNELS = (CONF_CHANNEL_1, CONF_CHANNEL_2, CONF_CHANNEL_3) + +# Configuration register layout: channel enables in bits 14..12, averaging in 11..9, +# bus conversion time in 8..6, shunt conversion time in 5..3, operating mode in 2..0 +CHANNEL_ENABLE_BITS = (1 << 14, 1 << 13, 1 << 12) +ADC_AVERAGING_SHIFT = 9 +ADC_TIME_BUS_SHIFT = 6 +ADC_TIME_SHUNT_SHIFT = 3 +MODE_SINGLE_SHOT = 0b011 +MODE_CONTINUOUS = 0b111 + +# Register field value by sample count and by conversion time in microseconds +ADC_AVERAGING = {1: 0, 4: 1, 16: 2, 64: 3, 128: 4, 256: 5, 512: 6, 1024: 7} +ADC_TIMES_US = {140: 0, 204: 1, 332: 2, 588: 3, 1100: 4, 2116: 5, 4156: 6, 8244: 7} + +# Alert limits are shunt voltages in 40 uV steps, held in bits 15..3 +ALERT_LIMIT_LSB_V = 40e-6 +ALERT_LIMIT_MAX_V = 0x0FFF * ALERT_LIMIT_LSB_V +# Headroom on top of the computed conversion time before the results are read +SINGLE_SHOT_MARGIN_MS = 5 ina3221_ns = cg.esphome_ns.namespace("ina3221") INA3221Component = ina3221_ns.class_( "INA3221Component", cg.PollingComponent, i2c.I2CDevice ) -INA3221_CHANNEL_SCHEMA = cv.Schema( - { - cv.Optional(CONF_BUS_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=2, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_SHUNT_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=2, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_CURRENT): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_POWER): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=2, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_SHUNT_RESISTANCE, default=0.1): cv.All( - cv.resistance, cv.Range(min=0.0, max=32.0) - ), - } +VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) +CURRENT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, +) +POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +CURRENT_LIMIT_SCHEMA = cv.All(cv.current, cv.Range(min=0.0, min_included=False)) + + +def validate_adc_time(value: Any) -> int: + value = cv.positive_time_period_microseconds(value).total_microseconds + return cv.one_of(*ADC_TIMES_US, int=True)(value) + + +def validate_alert_limits(config: ConfigType) -> ConfigType: + shunt = config[CONF_SHUNT_RESISTANCE] + for key in (CONF_WARNING_CURRENT_LIMIT, CONF_CRITICAL_CURRENT_LIMIT): + if (limit := config.get(key)) is None: + continue + if limit * shunt > ALERT_LIMIT_MAX_V: + raise cv.Invalid( + f"{key} times shunt_resistance exceeds the " + f"{ALERT_LIMIT_MAX_V * 1000:.1f} mV alert range", + path=[key], + ) + if alert_limit_register(limit, shunt) == 0: + raise cv.Invalid( + f"{key} times shunt_resistance is below the " + f"{ALERT_LIMIT_LSB_V * 1e6:.0f} µV alert resolution", + path=[key], + ) + return config + + +INA3221_CHANNEL_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_BUS_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_SHUNT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_POWER): POWER_SCHEMA, + cv.Optional(CONF_SHUNT_RESISTANCE, default=0.1): cv.All( + cv.resistance, cv.Range(min=0.0, max=32.0) + ), + cv.Optional(CONF_WARNING_CURRENT_LIMIT): CURRENT_LIMIT_SCHEMA, + cv.Optional(CONF_CRITICAL_CURRENT_LIMIT): CURRENT_LIMIT_SCHEMA, + } + ), + cv.has_at_least_one_key( + CONF_BUS_VOLTAGE, + CONF_SHUNT_VOLTAGE, + CONF_CURRENT, + CONF_POWER, + CONF_WARNING_CURRENT_LIMIT, + CONF_CRITICAL_CURRENT_LIMIT, + ), + validate_alert_limits, ) -CONFIG_SCHEMA = ( + +def adc_times_us(config: ConfigType) -> tuple[int, int]: + """Return the bus and shunt conversion times in microseconds.""" + adc_time = config[CONF_ADC_TIME] + if isinstance(adc_time, dict): + return adc_time[CONF_BUS_VOLTAGE], adc_time[CONF_SHUNT_VOLTAGE] + return adc_time, adc_time + + +def single_shot_wait_ms(config: ConfigType) -> int: + """Time one conversion of every enabled channel takes, plus headroom.""" + bus_us, shunt_us = adc_times_us(config) + channels = sum(1 for channel in CHANNELS if channel in config) + total_us = (bus_us + shunt_us) * config[CONF_ADC_AVERAGING] * channels + return -(-total_us // 1000) + SINGLE_SHOT_MARGIN_MS + + +def validate_single_shot_interval(config: ConfigType) -> ConfigType: + if config[CONF_CONTINUOUS_MODE]: + return config + wait_ms = single_shot_wait_ms(config) + if config[CONF_UPDATE_INTERVAL].total_milliseconds <= wait_ms: + raise cv.Invalid( + f"A single-shot conversion of all channels takes {wait_ms} ms, which does " + f"not fit in the update_interval; lower {CONF_ADC_AVERAGING} or " + f"{CONF_ADC_TIME}, or raise the interval", + path=[CONF_UPDATE_INTERVAL], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(INA3221Component), cv.Optional(CONF_CHANNEL_1): INA3221_CHANNEL_SCHEMA, cv.Optional(CONF_CHANNEL_2): INA3221_CHANNEL_SCHEMA, cv.Optional(CONF_CHANNEL_3): INA3221_CHANNEL_SCHEMA, + cv.Optional(CONF_CONTINUOUS_MODE, default=True): cv.boolean, + cv.Optional(CONF_ADC_AVERAGING, default=1): cv.one_of( + *ADC_AVERAGING, int=True + ), + cv.Optional(CONF_ADC_TIME, default="8244us"): cv.Any( + validate_adc_time, + cv.Schema( + { + cv.Required(CONF_BUS_VOLTAGE): validate_adc_time, + cv.Required(CONF_SHUNT_VOLTAGE): validate_adc_time, + } + ), + ), + cv.Optional(CONF_SUM_SHUNT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_SUM_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_SUM_POWER): POWER_SCHEMA, } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x40)) + .extend(i2c.i2c_device_schema(0x40)), + cv.has_at_least_one_key(*CHANNELS), + validate_single_shot_interval, ) +def alert_limit_register(current_a: float, shunt_ohm: float) -> int: + return round(current_a * shunt_ohm / ALERT_LIMIT_LSB_V) << 3 + + 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) - for i, channel in enumerate([CONF_CHANNEL_1, CONF_CHANNEL_2, CONF_CHANNEL_3]): - if channel not in config: + bus_us, shunt_us = adc_times_us(config) + register = ( + ADC_AVERAGING[config[CONF_ADC_AVERAGING]] << ADC_AVERAGING_SHIFT + | ADC_TIMES_US[bus_us] << ADC_TIME_BUS_SHIFT + | ADC_TIMES_US[shunt_us] << ADC_TIME_SHUNT_SHIFT + | (MODE_CONTINUOUS if config[CONF_CONTINUOUS_MODE] else MODE_SINGLE_SHOT) + ) + for i, channel in enumerate(CHANNELS): + if (conf := config.get(channel)) is None: continue - conf = config[channel] - if CONF_SHUNT_RESISTANCE in conf: - cg.add(var.set_shunt_resistance(i, conf[CONF_SHUNT_RESISTANCE])) + register |= CHANNEL_ENABLE_BITS[i] + shunt = conf[CONF_SHUNT_RESISTANCE] + cg.add(var.set_shunt_resistance(i, shunt)) if CONF_BUS_VOLTAGE in conf: sens = await sensor.new_sensor(conf[CONF_BUS_VOLTAGE]) cg.add(var.set_bus_voltage_sensor(i, sens)) @@ -98,3 +228,23 @@ async def to_code(config: ConfigType) -> None: if CONF_POWER in conf: sens = await sensor.new_sensor(conf[CONF_POWER]) cg.add(var.set_power_sensor(i, sens)) + for key, setter in ( + (CONF_WARNING_CURRENT_LIMIT, var.set_warning_limit), + (CONF_CRITICAL_CURRENT_LIMIT, var.set_critical_limit), + ): + if (limit := conf.get(key)) is not None: + cg.add_define("USE_INA3221_ALERT_LIMITS") + cg.add(setter(i, alert_limit_register(limit, shunt))) + cg.add(var.set_config_register(register)) + if not config[CONF_CONTINUOUS_MODE]: + cg.add(var.set_single_shot_wait_ms(single_shot_wait_ms(config))) + + if any( + key in config + for key in (CONF_SUM_SHUNT_VOLTAGE, CONF_SUM_CURRENT, CONF_SUM_POWER) + ): + cg.add_define("USE_INA3221_SUMMATION") + sensors = sensor.sub_sensors(config) + await sensors(CONF_SUM_SHUNT_VOLTAGE, var.set_sum_shunt_voltage_sensor) + await sensors(CONF_SUM_CURRENT, var.set_sum_current_sensor) + await sensors(CONF_SUM_POWER, var.set_sum_power_sensor) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1a5ab82d93..6f2a499730 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -91,6 +91,8 @@ #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE #define USE_IMPROV_BLE_STATE_CALLBACK +#define USE_INA3221_ALERT_LIMITS +#define USE_INA3221_SUMMATION #define USE_INFRARED #define USE_IR_RF #define USE_IR_RF_TRANSMIT_COMPLETE diff --git a/tests/components/ina3221/common.yaml b/tests/components/ina3221/common.yaml index 570d1b0a12..b836333ad8 100644 --- a/tests/components/ina3221/common.yaml +++ b/tests/components/ina3221/common.yaml @@ -13,3 +13,32 @@ sensor: shunt_voltage: name: INA3221 Channel 1 Shunt Voltage update_interval: 15s + - platform: ina3221 + i2c_id: i2c_bus + address: 0x41 + continuous_mode: false + adc_averaging: 4 + adc_time: + bus_voltage: 2116us + shunt_voltage: 1.1ms + channel_1: + shunt_resistance: 0.05 ohm + current: + name: INA3221 B Channel 1 Current + warning_current_limit: 1A + critical_current_limit: 2A + channel_2: + shunt_resistance: 0.05 ohm + bus_voltage: + name: INA3221 B Channel 2 Bus Voltage + channel_3: + shunt_resistance: 0.05 ohm + power: + name: INA3221 B Channel 3 Power + sum_shunt_voltage: + name: INA3221 B Sum Shunt Voltage + sum_current: + name: INA3221 B Sum Current + sum_power: + name: INA3221 B Sum Power + update_interval: 15s diff --git a/tests/components/ina3221/test.esp32-idf.yaml b/tests/components/ina3221/test.esp32-idf.yaml index b47e39c389..9ebc258996 100644 --- a/tests/components/ina3221/test.esp32-idf.yaml +++ b/tests/components/ina3221/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + ina3221: !include common.yaml diff --git a/tests/components/ina3221/test.esp8266-ard.yaml b/tests/components/ina3221/test.esp8266-ard.yaml index 4a98b9388a..c060740454 100644 --- a/tests/components/ina3221/test.esp8266-ard.yaml +++ b/tests/components/ina3221/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + ina3221: !include common.yaml diff --git a/tests/components/ina3221/test.rp2040-ard.yaml b/tests/components/ina3221/test.rp2040-ard.yaml index 319a7c71a6..257e90c0e6 100644 --- a/tests/components/ina3221/test.rp2040-ard.yaml +++ b/tests/components/ina3221/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + ina3221: !include common.yaml From f829447b2e711f1c8b0d6991521542eefd211407 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:25:49 -0400 Subject: [PATCH 265/268] [tmp102] Add number entities for runtime thermostat thresholds (#20596) 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/tmp102/__init__.py | 41 +++- esphome/components/tmp102/number.py | 87 ++++++++ esphome/components/tmp102/sensor.py | 15 +- esphome/components/tmp102/tmp102.cpp | 119 ++++++++++- esphome/components/tmp102/tmp102.h | 43 ++++ esphome/core/defines.h | 1 + .../tmp102/config/duplicate_numbers.yaml | 25 +++ .../tmp102/config/numbers.yaml | 24 +++ .../tmp102/config/numbers_inherit.yaml | 25 +++ .../tmp102/config/numbers_initial_value.yaml | 22 ++ tests/component_tests/tmp102/test_config.py | 55 +++++ tests/components/tmp102/__init__.py | 10 +- tests/components/tmp102/common.yaml | 10 + tests/components/tmp102/test_tmp102.cpp | 190 +++++++++++++++++- 14 files changed, 653 insertions(+), 14 deletions(-) create mode 100644 esphome/components/tmp102/number.py create mode 100644 tests/component_tests/tmp102/config/duplicate_numbers.yaml create mode 100644 tests/component_tests/tmp102/config/numbers.yaml create mode 100644 tests/component_tests/tmp102/config/numbers_inherit.yaml create mode 100644 tests/component_tests/tmp102/config/numbers_initial_value.yaml diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 077dd03ccc..9b4cf6680a 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -1,10 +1,14 @@ """Shared TMP102 types and configuration validation.""" +from dataclasses import dataclass, field +import math + 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 +from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType @@ -16,6 +20,7 @@ tmp102_ns = cg.esphome_ns.namespace("tmp102") TMP102Component = tmp102_ns.class_( "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor ) +TMP102LimitType = tmp102_ns.enum("TMP102LimitType") CONF_EXTENDED_MODE = "extended_mode" CONF_ONE_SHOT_MODE = "one_shot_mode" @@ -34,10 +39,40 @@ TMP102_CONVERSION_FACTOR = 0.0625 TMP102_DEFAULT_THIGH = 80.0 TMP102_DEFAULT_TLOW = 75.0 +TMP102_MIN_TEMPERATURE = -55.0 +TMP102_MAX_TEMPERATURE = 127.9375 +TMP102_MAX_TEMPERATURE_EXTENDED = 150.0 + + +def max_temperature(extended: bool) -> float: + return TMP102_MAX_TEMPERATURE_EXTENDED if extended else TMP102_MAX_TEMPERATURE + + +@dataclass +class TMP102Data: + # Extended mode of each sensor that has threshold numbers, keyed by sensor ID. + number_parents: dict[str, bool] = field(default_factory=dict) + + +def _get_data() -> TMP102Data: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TMP102Data() + return CORE.data[DOMAIN] + + +def add_number_parent(parent_id: object, extended: bool) -> None: + _get_data().number_parents[str(parent_id)] = extended + + +def number_parent_extended(parent_id: object) -> bool | None: + """Return the extended mode of a sensor with threshold numbers, or None without numbers.""" + return _get_data().number_parents.get(str(parent_id)) + def encode_temperature(temperature: float, extended: bool) -> int: """Encode a temperature as a TMP102 limit-register word.""" - steps = round(temperature / TMP102_CONVERSION_FACTOR) + scaled = temperature / TMP102_CONVERSION_FACTOR + steps = int(scaled + math.copysign(0.5, scaled)) return (steps << (3 if extended else 4)) & 0xFFFF @@ -56,8 +91,8 @@ def build_configuration(config: ConfigType) -> int: 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 + t_min = TMP102_MIN_TEMPERATURE + t_max = max_temperature(extended) high = config.get(CONF_TEMPERATURE_HIGH) low = config.get(CONF_TEMPERATURE_LOW) for key, value in ((CONF_TEMPERATURE_HIGH, high), (CONF_TEMPERATURE_LOW, low)): diff --git a/esphome/components/tmp102/number.py b/esphome/components/tmp102/number.py new file mode 100644 index 0000000000..e353a8d118 --- /dev/null +++ b/esphome/components/tmp102/number.py @@ -0,0 +1,87 @@ +"""Adjustable TMP102 thermostat thresholds.""" + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_RESTORE_VALUE, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_CONFIG, + UNIT_CELSIUS, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import ( + CONF_EXTENDED_MODE, + CONF_TEMPERATURE_HIGH, + CONF_TEMPERATURE_LOW, + CONF_TMP102_ID, + TMP102_CONVERSION_FACTOR, + TMP102_MIN_TEMPERATURE, + TMP102Component, + TMP102LimitType, + add_number_parent, + max_temperature, + number_parent_extended, + tmp102_ns, + validate_unique_child, +) + +TMP102LimitNumber = tmp102_ns.class_( + "TMP102LimitNumber", number.Number, cg.Parented.template(TMP102Component) +) + +_LIMIT_SCHEMA = number.number_schema( + TMP102LimitNumber, + unit_of_measurement=UNIT_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_category=ENTITY_CATEGORY_CONFIG, +).extend({cv.Optional(CONF_RESTORE_VALUE, default=True): cv.boolean}) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + cv.Optional(CONF_TEMPERATURE_HIGH): _LIMIT_SCHEMA, + cv.Optional(CONF_TEMPERATURE_LOW): _LIMIT_SCHEMA, + } + ), + cv.has_at_least_one_key(CONF_TEMPERATURE_HIGH, CONF_TEMPERATURE_LOW), +) + + +def _final_validate(config: ConfigType) -> ConfigType: + validate_unique_child(config, "number") + full = fv.full_config.get() + parent = full.get_config_for_path(full.get_path_for_id(config[CONF_TMP102_ID])[:-1]) + add_number_parent(config[CONF_TMP102_ID], parent.get(CONF_EXTENDED_MODE, False)) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_NUMBER") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + limits = { + "min_value": TMP102_MIN_TEMPERATURE, + "max_value": max_temperature(number_parent_extended(config[CONF_TMP102_ID])), + "step": TMP102_CONVERSION_FACTOR, + } + numbers = number.sub_numbers(config, parent=parent) + if var := await numbers( + CONF_TEMPERATURE_HIGH, + parent.set_limit_control, + TMP102LimitType.TMP102_LIMIT_HIGH, + **limits, + ): + cg.add(var.set_restore_value(config[CONF_TEMPERATURE_HIGH][CONF_RESTORE_VALUE])) + if var := await numbers( + CONF_TEMPERATURE_LOW, + parent.set_limit_control, + TMP102LimitType.TMP102_LIMIT_LOW, + **limits, + ): + cg.add(var.set_restore_value(config[CONF_TEMPERATURE_LOW][CONF_RESTORE_VALUE])) diff --git a/esphome/components/tmp102/sensor.py b/esphome/components/tmp102/sensor.py index fbfed7444d..c1eda11815 100644 --- a/esphome/components/tmp102/sensor.py +++ b/esphome/components/tmp102/sensor.py @@ -5,6 +5,7 @@ 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_ID, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, @@ -28,6 +29,7 @@ from . import ( TMP102Component, build_configuration, encode_temperature, + number_parent_extended, validate_tmp102_thresholds, ) @@ -66,17 +68,20 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if not any(key in config for key in _ADVANCED_SCHEMA): + has_numbers = number_parent_extended(config[CONF_ID]) is not None + if not has_numbers and 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), + encode_temperature( + config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH), extended + ), + encode_temperature( + config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW), extended + ), ) ) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 09b29dd7ef..bb82975dbc 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include + namespace esphome::tmp102 { ESPHOME_LOG_TAG(TAG, "tmp102"); @@ -18,6 +20,9 @@ static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; #ifdef USE_TMP102_CONFIGURE static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; +#ifdef USE_TMP102_NUMBER +static_assert(TMP102_REGISTER_LOW_LIMIT + TMP102_LIMIT_HIGH == TMP102_REGISTER_HIGH_LIMIT); +#endif static constexpr uint16_t TMP102_CONFIGURATION_MASK = 0x1FD0; static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; @@ -31,11 +36,39 @@ static float decode_temperature(uint16_t raw, bool extended) { return (static_cast(raw) >> (extended ? 3 : 4)) * TMP102_CONVERSION_FACTOR; } +#ifdef USE_TMP102_NUMBER +static uint16_t encode_temperature(float temperature, bool extended) { + const int16_t steps = static_cast(std::lround(temperature / TMP102_CONVERSION_FACTOR)); + return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); +} +#endif + void TMP102Component::setup() { if (!this->configure_) return; - if (!this->write_configuration_()) +#ifdef USE_TMP102_NUMBER + float restored[2]{}; + this->prepare_number_limits_(restored); +#endif + if (!this->write_configuration_()) { this->mark_failed(); + return; + } +#ifdef USE_TMP102_NUMBER + bool saved = true; + for (uint8_t limit = 0; limit < 2; ++limit) { + TMP102LimitNumber *control = this->limit_controls_[limit]; + if (control == nullptr) + continue; + const float value = this->get_limit_temperature_(static_cast(limit)); + if (value != restored[limit]) + saved &= control->save_value_(value); + control->publish_state(value); + } + if (!saved) { + ESP_LOGW(TAG, "Failed to save threshold preferences"); + } +#endif } #endif @@ -75,6 +108,10 @@ void TMP102Component::dump_config() { 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)); +#ifdef USE_TMP102_NUMBER + LOG_NUMBER(" ", "High Limit", this->limit_controls_[TMP102_LIMIT_HIGH]); + LOG_NUMBER(" ", "Low Limit", this->limit_controls_[TMP102_LIMIT_LOW]); +#endif if (this->config_ & TMP102_THERMOSTAT_MODE_BIT) { ESP_LOGW(TAG, "Register reads clear the interrupt-mode ALERT pin"); } @@ -185,6 +222,55 @@ void TMP102Component::read_temperature_() { #endif this->status_clear_warning(); } + +#ifdef USE_TMP102_NUMBER +void TMP102Component::set_limit_control(TMP102LimitNumber *number) { + this->limit_controls_[number->limit_type_] = number; +} + +void TMP102Component::prepare_number_limits_(float *restored) { + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + uint16_t raw[2] = {this->low_limit_, this->high_limit_}; + for (uint8_t limit = 0; limit < 2; ++limit) { + TMP102LimitNumber *control = this->limit_controls_[limit]; + if (control == nullptr) + continue; + restored[limit] = control->load_value_(decode_temperature(raw[limit], extended)); + raw[limit] = encode_temperature(restored[limit], extended); + } + if (static_cast(raw[TMP102_LIMIT_LOW]) > static_cast(raw[TMP102_LIMIT_HIGH])) { + ESP_LOGW(TAG, "Restored thresholds are inverted; using configured values"); + return; + } + this->low_limit_ = raw[TMP102_LIMIT_LOW]; + this->high_limit_ = raw[TMP102_LIMIT_HIGH]; +} + +float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const { + const uint16_t raw = limit == TMP102_LIMIT_HIGH ? this->high_limit_ : this->low_limit_; + return decode_temperature(raw, this->config_ & TMP102_EXTENDED_MODE_BIT); +} + +bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temperature) { + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + const uint16_t raw = encode_temperature(temperature, extended); + const bool high = limit == TMP102_LIMIT_HIGH; + const uint16_t other = high ? this->low_limit_ : this->high_limit_; + if (high ? static_cast(raw) < static_cast(other) + : static_cast(raw) > static_cast(other)) { + ESP_LOGW(TAG, "Rejected %s threshold %.4f°C: crosses %s threshold %.4f°C", + high ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"), decode_temperature(raw, extended), + high ? LOG_STR_LITERAL("low") : LOG_STR_LITERAL("high"), decode_temperature(other, extended)); + return false; + } + if (!this->write_register_(static_cast(TMP102_REGISTER_LOW_LIMIT + limit), raw)) { + this->status_set_warning(); + return false; + } + (high ? this->high_limit_ : this->low_limit_) = raw; + return true; +} +#endif #endif #ifdef USE_TMP102_BINARY_SENSOR @@ -207,4 +293,35 @@ bool TMP102Component::read_alert_state_() { } #endif +#ifdef USE_TMP102_NUMBER +float TMP102LimitNumber::load_value_(float fallback) { + float value = fallback; + if (this->restore_value_) { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&value); + } + if (!std::isfinite(value) || value < this->traits.get_min_value() || value > this->traits.get_max_value()) { + ESP_LOGW(TAG, "Invalid restored threshold; using configured value"); + value = fallback; + } + return value; +} + +bool TMP102LimitNumber::save_value_(float value) { return !this->restore_value_ || this->pref_.save(&value); } + +void TMP102LimitNumber::control(float value) { + // A chip that failed setup holds nothing the entity could show + if (this->parent_->is_failed()) { + ESP_LOGW(TAG, "Rejected threshold change: sensor setup failed"); + return; + } + const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value); + value = this->parent_->get_limit_temperature_(this->limit_type_); + this->publish_state(value); + if (accepted && !this->save_value_(value)) { + ESP_LOGW(TAG, "Failed to save threshold preference"); + } +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index caf75e19be..d88dde7834 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -6,12 +6,26 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif #include "esphome/components/i2c/i2c.h" +#ifdef USE_TMP102_NUMBER +#include "esphome/components/number/number.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" +#endif #include "esphome/components/sensor/sensor.h" #include namespace esphome::tmp102 { +#ifdef USE_TMP102_NUMBER +enum TMP102LimitType : uint8_t { + TMP102_LIMIT_LOW = 0, + TMP102_LIMIT_HIGH = 1, +}; + +class TMP102LimitNumber; +#endif + class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: #ifdef USE_TMP102_BINARY_SENSOR @@ -25,6 +39,9 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu this->low_limit_ = low_limit; this->configure_ = true; } +#ifdef USE_TMP102_NUMBER + void set_limit_control(TMP102LimitNumber *number); +#endif #endif void dump_config() override; void update() override; @@ -46,6 +63,14 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu bool check_configuration_(); void read_temperature_(); void update_configured_(); +#ifdef USE_TMP102_NUMBER + friend class TMP102LimitNumber; + bool set_limit_temperature_(TMP102LimitType limit, float temperature); + float get_limit_temperature_(TMP102LimitType limit) const; + void prepare_number_limits_(float *restored); + + TMP102LimitNumber *limit_controls_[2]{}; +#endif uint16_t config_{0}; uint16_t high_limit_{0}; @@ -54,4 +79,22 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu #endif }; +#ifdef USE_TMP102_NUMBER +class TMP102LimitNumber final : public number::Number, public Parented { + public: + explicit TMP102LimitNumber(TMP102LimitType limit_type) : limit_type_(limit_type) {} + void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } + + protected: + friend class TMP102Component; + float load_value_(float fallback); + bool save_value_(float value); + void control(float value) override; + + ESPPreferenceObject pref_; + TMP102LimitType limit_type_; + bool restore_value_{true}; +}; +#endif + } // namespace esphome::tmp102 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6f2a499730..fc6dd82ed9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -266,6 +266,7 @@ #define USE_TIME_TRIGGERS #define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE +#define USE_TMP102_NUMBER #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX diff --git a/tests/component_tests/tmp102/config/duplicate_numbers.yaml b/tests/component_tests/tmp102/config/duplicate_numbers.yaml new file mode 100644 index 0000000000..04f634741f --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_numbers.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High One + - platform: tmp102 + tmp102_id: chip + temperature_low: + name: Low Two diff --git a/tests/component_tests/tmp102/config/numbers.yaml b/tests/component_tests/tmp102/config/numbers.yaml new file mode 100644 index 0000000000..272bd0dfe6 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers.yaml @@ -0,0 +1,24 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High + temperature_low: + name: Low + restore_value: false diff --git a/tests/component_tests/tmp102/config/numbers_inherit.yaml b/tests/component_tests/tmp102/config/numbers_inherit.yaml new file mode 100644 index 0000000000..cbd4280f03 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers_inherit.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + extended_mode: true + temperature_high: 140 + temperature_low: 120 + +number: + - platform: tmp102 + temperature_high: + name: High + temperature_low: + name: Low diff --git a/tests/component_tests/tmp102/config/numbers_initial_value.yaml b/tests/component_tests/tmp102/config/numbers_initial_value.yaml new file mode 100644 index 0000000000..60d2557701 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers_initial_value.yaml @@ -0,0 +1,22 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High + initial_value: 30 diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py index 49f2629baf..b933db1d9b 100644 --- a/tests/component_tests/tmp102/test_config.py +++ b/tests/component_tests/tmp102/test_config.py @@ -58,6 +58,54 @@ def test_duplicate_alert_entities_are_rejected( ) +def test_threshold_numbers_enable_configuration_once( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("numbers.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(128, 20480, 19200)" in source + assert source.count("set_limit_control(") == 2 + assert "tmp102::TMP102_LIMIT_HIGH" in source + assert "tmp102::TMP102_LIMIT_LOW" in source + assert "set_restore_value(false)" in source + assert "set_max_value(127.9375f)" in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_NUMBER" in defines + assert "USE_TMP102_CONFIGURE" in defines + + +def test_threshold_numbers_use_sensor_thresholds( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("numbers_inherit.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(144, 17920, 15360)" in source + assert "set_max_value(150.0f)" in source + + +def test_threshold_number_initial_value_is_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("numbers_initial_value.yaml") + assert read_config({}) is None + assert "[initial_value] is an invalid option" in capsys.readouterr().out + + +def test_duplicate_numbers_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_numbers.yaml") + assert read_config({}) is None + assert ( + "Only one TMP102 number may reference each temperature sensor" + in capsys.readouterr().out + ) + + @pytest.mark.parametrize( ("extended", "high", "low"), [ @@ -117,5 +165,12 @@ def test_precomputed_register_values() -> None: assert component.encode_temperature(config["temperature_low"], True) == 0xF9E0 +def test_threshold_encoding_rounds_half_steps_away_from_zero() -> None: + assert component.encode_temperature(30.03125, False) == 481 << 4 + assert component.encode_temperature(-30.03125, False) == (-481 << 4) & 0xFFFF + assert component.encode_temperature(30.03125, True) == 481 << 3 + assert component.encode_temperature(-30.03125, True) == (-481 << 3) & 0xFFFF + + 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 index f86177c0f9..346efac83e 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,10 +6,16 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - for platform in ("sensor", "binary_sensor"): + for platform in ("sensor", "binary_sensor", "number"): CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") cg.add_define("USE_TMP102_BINARY_SENSOR") + cg.add_define("USE_TMP102_NUMBER") manifest.to_code = to_code_testing - manifest.dependencies = manifest.dependencies + ["i2c", "sensor", "binary_sensor"] + manifest.dependencies = manifest.dependencies + [ + "i2c", + "sensor", + "binary_sensor", + "number", + ] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index 1e5f1f456c..dfc2e2e224 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -16,6 +16,16 @@ sensor: temperature_high: 140°C temperature_low: 120°C +number: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + temperature_high: + name: TMP102 High Threshold + restore_value: true + temperature_low: + name: TMP102 Low Threshold + restore_value: false + binary_sensor: - platform: tmp102 tmp102_id: tmp102_extended_temperature diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index cd06d8a83d..6c294f4cdc 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -3,12 +3,23 @@ #include #include +#include +#include +#include "esphome/components/host/preferences.h" #include "esphome/components/tmp102/tmp102.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" namespace esphome::tmp102::testing { +class EntityIdentity : public EntityBase { + public: + static void configure(EntityBase &entity, const char *name, uint32_t hash) { + auto configure = &EntityIdentity::configure_entity_; + (entity.*configure)(name, hash, 0); + } +}; + class RegisterBus final : public i2c::I2CBus { public: std::array registers{0x1900, 0x60A0, 0x4B00, 0x5000}; @@ -48,21 +59,32 @@ class TMP102Test : public ::testing::Test { RegisterBus bus_; TMP102Component chip_; binary_sensor::BinarySensor alert_; + TMP102LimitNumber high_{TMP102_LIMIT_HIGH}; + TMP102LimitNumber low_{TMP102_LIMIT_LOW}; 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); + App.pre_setup("tmp102-regression-tests", 23, "TMP102 tests", 12); + host::setup_preferences(); + host::host_preferences->reset(); this->chip_.set_i2c_bus(&this->bus_); this->chip_.set_i2c_address(0x48); this->chip_.add_on_state_callback([this](float) { ++this->publications_; }); + EntityIdentity::configure(this->high_, "High", 0x102001); + EntityIdentity::configure(this->low_, "Low", 0x102002); + for (auto *number : {&this->high_, &this->low_}) { + number->set_parent(&this->chip_); + number->traits.set_min_value(-55); + number->traits.set_max_value(127.9375f); + number->traits.set_step(0.0625f); + } } void TearDown() override { App.scheduler.cancel_timeout(&this->chip_, uint32_t{0}); App.scheduler.call(millis()); + host::host_preferences->reset(); App.~Application(); } @@ -75,6 +97,31 @@ class TMP102Test : public ::testing::Test { void configure(uint16_t config = 0x0080, uint16_t high = 0x5000, uint16_t low = 0x4B00) { this->chip_.set_configuration(config, high, low); } + + static uint16_t encode(float temperature, bool extended = false) { + const int16_t steps = static_cast(std::lround(temperature / 0.0625f)); + return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); + } + + void numbers_(float initial_high = 30, float initial_low = 25, bool extended = false) { + this->configure(extended ? 0x0090 : 0x0080, encode(initial_high, extended), encode(initial_low, extended)); + if (extended) { + this->high_.traits.set_max_value(150); + this->low_.traits.set_max_value(150); + } + this->chip_.set_limit_control(&this->high_); + this->chip_.set_limit_control(&this->low_); + } + + static float saved(TMP102LimitNumber &number) { + float value = NAN; + EXPECT_TRUE(number.make_entity_preference().load(&value)); + return value; + } + + static void save(TMP102LimitNumber &number, float value) { + ASSERT_TRUE(number.make_entity_preference().save(&value)); + } }; TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { @@ -190,6 +237,62 @@ TEST_F(TMP102Test, OneShotPublishesAlertAfterDelayedRead) { EXPECT_FALSE(this->chip_.status_has_warning()); } +TEST_F(TMP102Test, QuantizedRuntimeValuesMatchRegistersAndPreferences) { + this->numbers_((10.0f - 32) * 5 / 9, (9.0f - 32) * 5 / 9); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, -12.25f); + EXPECT_FLOAT_EQ(this->low_.state, -12.75f); + EXPECT_EQ(this->bus_.registers[3], 0xF3C0); + for (auto [requested, expected] : + {std::pair{12.22f, 12.25f}, {-12.22f, -12.25f}, {12.03125f, 12.0625f}, {-12.03125f, -12.0625f}}) { + this->high_.make_call().set_value(requested).perform(); + EXPECT_FLOAT_EQ(this->high_.state, expected); + EXPECT_FLOAT_EQ(saved(this->high_), expected); + EXPECT_FLOAT_EQ(static_cast(this->bus_.registers[3]) / 256.0f, expected); + } +} + +TEST_F(TMP102Test, ExtendedModeRuntimeValueMatchesRegister) { + this->numbers_(140, 120, true); + this->chip_.setup(); + this->high_.make_call().set_value(145.0625f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 145.0625f); + EXPECT_EQ(this->bus_.registers[3], encode(145.0625f, true)); + EXPECT_FLOAT_EQ(saved(this->high_), 145.0625f); +} + +TEST_F(TMP102Test, HighOnlyInvalidRestoreFallsBackAgainstStaticLow) { + this->configure(0x0080, encode(30), encode(25)); + this->chip_.set_limit_control(&this->high_); + save(this->high_, 20); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_EQ(this->bus_.registers[3], encode(30)); + EXPECT_EQ(this->bus_.registers[2], encode(25)); + EXPECT_FLOAT_EQ(saved(this->high_), 30); +} + +TEST_F(TMP102Test, OrderingUsesAcceptedQuantizedThreshold) { + this->numbers_(30.02f, 30.01f); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 30); + this->high_.make_call().set_value(29.96f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + this->high_.make_call().set_value(29.99f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 30); +} + +TEST_F(TMP102Test, FailedThresholdWriteWarningClearsAfterSuccessfulRead) { + this->numbers_(); + this->chip_.setup(); + this->bus_.fail_write = 3; + this->high_.make_call().set_value(90).perform(); + EXPECT_TRUE(this->chip_.status_has_warning()); + this->chip_.update(); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { this->configure(0x0090, 0x2D00, 0x2A80); this->chip_.setup(); @@ -304,6 +407,87 @@ TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { } } +TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { + this->numbers_(); + save(this->high_, 20); + save(this->low_, 35); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_FLOAT_EQ(saved(this->high_), 30); + EXPECT_FLOAT_EQ(saved(this->low_), 25); +} + +TEST_F(TMP102Test, InvalidRestoredValuesAreRepaired) { + this->numbers_(); + save(this->high_, NAN); + save(this->low_, INFINITY); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); +} + +TEST_F(TMP102Test, FailedSetupDoesNotOverwritePreferences) { + this->numbers_(); + save(this->high_, 90); + save(this->low_, 85); + this->bus_.fail_write = 3; + this->chip_.setup(); + EXPECT_TRUE(this->chip_.is_failed()); + EXPECT_FALSE(this->high_.has_state()); + EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_FLOAT_EQ(saved(this->low_), 85); +} + +TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { + this->numbers_(); + save(this->high_, 90); + this->bus_.fail_write = 3; + this->chip_.setup(); + ASSERT_TRUE(this->chip_.is_failed()); + const unsigned writes = this->bus_.writes; + this->high_.make_call().set_value(60).perform(); + EXPECT_FALSE(this->high_.has_state()); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + +TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { + this->numbers_(); + save(this->high_, 35); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 35); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_EQ(this->bus_.registers[3], encode(35)); + float value; + EXPECT_FALSE(this->low_.make_entity_preference().load(&value)); +} + +TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { + this->numbers_(); + save(this->high_, 90); + save(this->low_, 85); + this->chip_.setup(); + unsigned publications = 0; + this->high_.add_on_state_callback([&publications](float) { ++publications; }); + this->bus_.fail_write = 3; + this->high_.make_call().set_value(100).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 90); + EXPECT_EQ(publications, 1); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + +TEST_F(TMP102Test, RestoreDisabledNeverChangesSavedValue) { + this->numbers_(); + save(this->high_, 90); + this->high_.set_restore_value(false); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + this->high_.make_call().set_value(40).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 40); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + } // namespace esphome::tmp102::testing #endif From 8acf885241946a80673d3687f09f9a80d9f6e692 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Tue, 13 Oct 2026 04:43:51 +0200 Subject: [PATCH 266/268] [bq76972] Add BQ76972 BMS chip monitoring component (#17720) 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 --- CODEOWNERS | 1 + esphome/components/bq76972/__init__.py | 111 +++++++ esphome/components/bq76972/bq76972.cpp | 298 ++++++++++++++++++ esphome/components/bq76972/bq76972.h | 51 +++ tests/components/bq76972/common.yaml | 59 ++++ tests/components/bq76972/test.esp32-idf.yaml | 3 + .../components/bq76972/test.esp8266-ard.yaml | 3 + 7 files changed, 526 insertions(+) create mode 100644 esphome/components/bq76972/__init__.py create mode 100644 esphome/components/bq76972/bq76972.cpp create mode 100644 esphome/components/bq76972/bq76972.h create mode 100644 tests/components/bq76972/common.yaml create mode 100644 tests/components/bq76972/test.esp32-idf.yaml create mode 100644 tests/components/bq76972/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 075b8113d8..f068a3a221 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -106,6 +106,7 @@ esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid esphome/components/bq25186/* @Rapsssito esphome/components/bq27220/* @rggammon +esphome/components/bq76972/* @limpkin esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py new file mode 100644 index 0000000000..5ccf043c25 --- /dev/null +++ b/esphome/components/bq76972/__init__.py @@ -0,0 +1,111 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_ID, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_VOLT, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["sensor"] +CODEOWNERS = ["@limpkin"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bq76972" +MULTI_CONF = True + +bq76972_ns = cg.esphome_ns.namespace("bq76972") +BQ76972Component = bq76972_ns.class_( + "BQ76972Component", cg.PollingComponent, i2c.I2CDevice +) + +CONF_REG_DISABLED = "reg_disabled" +CONF_CRC_ENABLED = "crc_enabled" +CONF_STACK_VOLTAGE = "stack_voltage" + +# Ordered as the chip reports them, starting at command 0x68 +CONF_TEMPERATURES = [ + "internal_temperature", + "cfetoff_temperature", + "dfetoff_temperature", + "alert_temperature", + "ts1_temperature", + "ts2_temperature", + "ts3_temperature", + "hdq_temperature", + "dchg_temperature", + "ddsg_temperature", +] + +CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)] + +voltage_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) + +temp_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ76972Component), + cv.Optional(CONF_CRC_ENABLED, default=False): cv.boolean, + cv.Optional(CONF_REG_DISABLED, default=False): cv.boolean, + cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema, + **{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES}, + **{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES}, + } + ) + .extend(i2c.i2c_device_schema(0x08)) + .extend(cv.polling_component_schema("10s")) +) + + +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) + + cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED])) + cg.add(var.set_reg_disable(config[CONF_REG_DISABLED])) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_STACK_VOLTAGE, var.set_stack_voltage_sensor) + + for idx, key in enumerate(CONF_TEMPERATURES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(var.set_temperature_sensor(sens, idx)) + + for idx, key in enumerate(CONF_CELL_VOLTAGES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(var.set_cell_sensor(sens, idx)) + + +BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(BQ76972Component), + cv.Required(CONF_ADDRESS): cv.templatable(cv.i2c_address), + }, + key=CONF_ADDRESS, +) + +automation.register_apply_action( + "bq76972.program_i2c_address", + BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA, + automation.ApplyField(CONF_ADDRESS, "program_i2c_address", cg.uint8), +) diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp new file mode 100644 index 0000000000..ea229307d5 --- /dev/null +++ b/esphome/components/bq76972/bq76972.cpp @@ -0,0 +1,298 @@ +#include "bq76972.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bq76972 { + +ESPHOME_LOG_TAG(TAG, "bq76972"); + +static constexpr uint8_t BQ76972_REG_SUBCOMMAND = 0x3E; // 0x3E low byte, 0x3F high byte +static constexpr uint8_t BQ76972_REG_TRANSFER_BUFFER = 0x40; +static constexpr uint8_t BQ76972_REG_CHECKSUM = 0x60; // 0x60 checksum, 0x61 length +static constexpr uint8_t BQ76972_CMD_BAT_STATUS = 0x12; +static constexpr uint8_t BQ76972_CMD_CELL_START = 0x14; +static constexpr uint8_t BQ76972_CMD_STACK_VOLTAGE = 0x34; +static constexpr uint8_t BQ76972_CMD_TEMP_START = 0x68; +static constexpr uint16_t BQ76972_SUBCMD_ENTER_CFG_UPDATE = 0x0090; +static constexpr uint16_t BQ76972_SUBCMD_EXIT_CFG_UPDATE = 0x0092; +static constexpr uint16_t BQ76972_MEM_REG12_CONFIG = 0x9236; +static constexpr uint16_t BQ76972_MEM_REG0_CONFIG = 0x9237; +static constexpr uint16_t BQ76972_MEM_I2C_ADDRESS = 0x923A; +static constexpr uint16_t BQ76972_MEM_SWAP_COMM_MODE = 0x29BC; +// Pin config of temperature reading N (1 = CFETOFF ... 9 = DDSG) is at this base + N +static constexpr uint16_t BQ76972_MEM_TEMP_PIN_CONFIG_BASE = 0x92F9; +static constexpr size_t BQ76972_TS1_INDEX = 4; // a thermistor input by default +static constexpr uint8_t BQ76972_PIN_CONFIG_THERMISTOR = 0x07; + +// 16 words with a CRC byte after every data byte +static constexpr size_t BQ76972_MAX_TRANSFER = 64; +static constexpr uint8_t BQ76972_MAX_POLLS = 50; + +static float raw_to_celsius(uint16_t raw) { return static_cast(raw) * 0.1f - 273.15f; } + +bool BQ76972Component::read_block_(uint8_t start_register, uint8_t *data, size_t len) { + if (!this->crc_mode_) + return this->read_bytes(start_register, data, len); + + // With CRC enabled, every data byte is followed by a CRC byte + std::array buffer; + if (len == 0 || len * 2 > buffer.size()) + return false; + if (!this->read_bytes(start_register, buffer.data(), len * 2)) + return false; + + // The first CRC also covers the write address, register and read address + const uint8_t crc0_input[4] = {static_cast(this->address_ << 1), start_register, + static_cast((this->address_ << 1) | 1), buffer[0]}; + if (crc8(crc0_input, 4, 0x00, 0x07, true) != buffer[1]) { + ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte 0", start_register); + return false; + } + data[0] = buffer[0]; + for (size_t i = 1; i < len; ++i) { + if (crc8(&buffer[i * 2], 1, 0x00, 0x07, true) != buffer[i * 2 + 1]) { + ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte %u", start_register, static_cast(i)); + return false; + } + data[i] = buffer[i * 2]; + } + return true; +} + +bool BQ76972Component::write_block_(uint8_t start_register, const uint8_t *data, size_t len) { + if (!this->crc_mode_) + return this->write_bytes(start_register, data, len); + + std::array buffer; + if (len == 0 || len * 2 > buffer.size()) + return false; + + // The first CRC also covers the write address and register + const uint8_t crc0_input[3] = {static_cast(this->address_ << 1), start_register, data[0]}; + buffer[0] = data[0]; + buffer[1] = crc8(crc0_input, 3, 0x00, 0x07, true); + for (size_t i = 1; i < len; ++i) { + buffer[i * 2] = data[i]; + buffer[i * 2 + 1] = crc8(&data[i], 1, 0x00, 0x07, true); + } + return this->write_bytes(start_register, buffer.data(), len * 2); +} + +bool BQ76972Component::read_words_(uint8_t start_register, uint16_t *data, size_t num_words) { + std::array bytes; + if (num_words * 2 > bytes.size() || !this->read_block_(start_register, bytes.data(), num_words * 2)) + return false; + for (size_t i = 0; i < num_words; i++) + data[i] = encode_uint16(bytes[2 * i + 1], bytes[2 * i]); + return true; +} + +bool BQ76972Component::send_subcommand_(uint16_t subcommand) { + const uint8_t bytes[2] = {static_cast(subcommand & 0xFF), static_cast(subcommand >> 8)}; + return this->write_block_(BQ76972_REG_SUBCOMMAND, bytes, 2); +} + +bool BQ76972Component::wait_for_subcommand_() { + uint8_t bytes[2]; + for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) { + if (!this->read_block_(BQ76972_REG_SUBCOMMAND, bytes, 2)) + return false; + if (bytes[0] != 0xFF && bytes[1] != 0xFF) + return true; + delay(1); + } + return false; +} + +bool BQ76972Component::wait_for_cfgupdate_() { + uint16_t status; + for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) { + if (!this->read_words_(BQ76972_CMD_BAT_STATUS, &status, 1)) + return false; + if (status & 0x0001) + return true; + delay(1); + } + return false; +} + +bool BQ76972Component::read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len) { + return this->send_subcommand_(subcommand) && this->wait_for_subcommand_() && + this->read_block_(BQ76972_REG_TRANSFER_BUFFER, data, len); +} + +bool BQ76972Component::write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len) { + if (!this->send_subcommand_(subcommand)) + return false; + if (len == 0) + return true; + if (!this->write_block_(BQ76972_REG_TRANSFER_BUFFER, data, len)) + return false; + + // Inverted 8-bit sum of 0x3E, 0x3F and the data; length counts 0x3E, 0x3F, 0x60, 0x61 and the data + uint8_t sum = static_cast(subcommand & 0xFF) + static_cast(subcommand >> 8); + for (size_t i = 0; i < len; ++i) + sum += data[i]; + const uint8_t tail[2] = {static_cast(~sum), static_cast(4 + len)}; + return this->write_block_(BQ76972_REG_CHECKSUM, tail, 2); +} + +bool BQ76972Component::enter_cfg_update_() { + if (!this->write_subcommand_(BQ76972_SUBCMD_ENTER_CFG_UPDATE, nullptr, 0)) + return false; + delay(10); + return this->wait_for_cfgupdate_(); +} + +bool BQ76972Component::exit_cfg_update_() { + return this->write_subcommand_(BQ76972_SUBCMD_EXIT_CFG_UPDATE, nullptr, 0); +} + +bool BQ76972Component::regs_disabled_(bool &out) { + uint8_t reg12; + uint8_t reg0; + if (!this->read_subcommand_(BQ76972_MEM_REG12_CONFIG, ®12, 1) || + !this->read_subcommand_(BQ76972_MEM_REG0_CONFIG, ®0, 1)) + return false; + out = reg12 == 0 && (reg0 & 0x01) == 0; + return true; +} + +const LogString *BQ76972Component::configure_() { + uint16_t internal_temp; + if (!this->read_words_(BQ76972_CMD_TEMP_START, &internal_temp, 1)) + return LOG_STR(ESP_LOG_MSG_COMM_FAIL); + + // Only write the regulator config when it differs, see + // https://e2e.ti.com/support/power-management-group/power-management/f/power-management-forum/1583718/bq76972-undocumented-4-minute-config_update-auto-exit-timeout + bool regs_disabled = true; + if (this->reg_disable_ && !this->regs_disabled_(regs_disabled)) + return LOG_STR("Reading regulator config failed"); + const bool write_regs = !regs_disabled; + + bool write_thermistors = false; + for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { + if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr) + write_thermistors = true; + } + + if (write_regs || write_thermistors) { + if (!this->enter_cfg_update_()) + return LOG_STR("Entering config update mode failed"); + const LogString *error = this->write_config_(write_regs); + // The chip stops measuring in config update mode, so leave it even after a failed write + if (!this->exit_cfg_update_() && error == nullptr) + error = LOG_STR("Exiting config update mode failed"); + if (error != nullptr) + return error; + if (write_regs && (!this->regs_disabled_(regs_disabled) || !regs_disabled)) + return LOG_STR("Disabling regulators failed"); + } + + ESP_LOGD(TAG, "Internal temperature: %.1f°C", raw_to_celsius(internal_temp)); + return nullptr; +} + +const LogString *BQ76972Component::write_config_(bool write_regs) { + if (write_regs) { + const uint8_t zero = 0; + if (!this->write_subcommand_(BQ76972_MEM_REG12_CONFIG, &zero, 1) || + !this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1)) + return LOG_STR("Writing regulator config failed"); + } + for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { + if (i == BQ76972_TS1_INDEX || this->temperature_sensors_[i] == nullptr) + continue; + if (!this->write_subcommand_(BQ76972_MEM_TEMP_PIN_CONFIG_BASE + i, &BQ76972_PIN_CONFIG_THERMISTOR, 1)) + return LOG_STR("Writing thermistor pin config failed"); + } + return nullptr; +} + +void BQ76972Component::setup() { + if (const LogString *error = this->configure_(); error != nullptr) + this->mark_failed(error); +} + +void BQ76972Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ76972:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, + " CRC Mode: %s\n" + " Regulators disabled: %s", + this->crc_mode_ ? LOG_STR_LITERAL("Enabled") : LOG_STR_LITERAL("Disabled"), YESNO(this->reg_disable_)); +} + +void BQ76972Component::program_i2c_address(uint8_t address) { + if (this->is_failed()) + return; + const uint8_t shifted = address << 1; + if (!this->enter_cfg_update_()) { + this->status_set_warning(LOG_STR("Programming I2C address failed")); + return; + } + // The new address only takes effect with the swap, so after a failed write the chip still answers at the + // old one and must still be taken out of config update mode + const bool written = this->write_subcommand_(BQ76972_MEM_I2C_ADDRESS, &shifted, 1) && + this->write_subcommand_(BQ76972_MEM_SWAP_COMM_MODE, nullptr, 0); + if (written) { + delay(10); + this->set_i2c_address(address); + } + if (!this->exit_cfg_update_() || !written) { + this->status_set_warning(LOG_STR("Programming I2C address failed")); + return; + } + ESP_LOGI(TAG, "I2C address set to 0x%02X", address); +} + +template static bool any_set(const std::array &sensors) { + for (auto *sensor : sensors) { + if (sensor != nullptr) + return true; + } + return false; +} + +void BQ76972Component::update() { + // Each group publishes as soon as its read succeeds, so one failed read does not hold back the others + bool ok = true; + uint16_t words[BQ76972_NUM_CELLS]; + if (this->stack_voltage_sensor_ != nullptr) { + if (this->read_words_(BQ76972_CMD_STACK_VOLTAGE, words, 1)) { + if (words[0] != 0) + this->stack_voltage_sensor_->publish_state(words[0] * 0.01f); // 10 mV units + } else { + ok = false; + } + } + if (any_set(this->cell_sensors_)) { + if (this->read_words_(BQ76972_CMD_CELL_START, words, BQ76972_NUM_CELLS)) { + for (size_t i = 0; i < BQ76972_NUM_CELLS; i++) { + if (this->cell_sensors_[i] != nullptr && words[i] != 0) + this->cell_sensors_[i]->publish_state(words[i] * 0.001f); // mV + } + } else { + ok = false; + } + } + if (any_set(this->temperature_sensors_)) { + if (this->read_words_(BQ76972_CMD_TEMP_START, words, BQ76972_NUM_TEMPERATURES)) { + for (size_t i = 0; i < BQ76972_NUM_TEMPERATURES; i++) { + if (this->temperature_sensors_[i] != nullptr) + this->temperature_sensors_[i]->publish_state(raw_to_celsius(words[i])); + } + } else { + ok = false; + } + } + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +} // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/bq76972.h b/esphome/components/bq76972/bq76972.h new file mode 100644 index 0000000000..d27a4ce96f --- /dev/null +++ b/esphome/components/bq76972/bq76972.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" + +#include + +namespace esphome::bq76972 { + +static constexpr size_t BQ76972_NUM_CELLS = 16; +static constexpr size_t BQ76972_NUM_TEMPERATURES = 10; + +class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; } + void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; } + void set_stack_voltage_sensor(sensor::Sensor *s) { this->stack_voltage_sensor_ = s; } + void set_cell_sensor(sensor::Sensor *s, size_t index) { this->cell_sensors_[index] = s; } + void set_temperature_sensor(sensor::Sensor *s, size_t index) { this->temperature_sensors_[index] = s; } + + void program_i2c_address(uint8_t address); + + protected: + bool read_block_(uint8_t start_register, uint8_t *data, size_t len); + bool write_block_(uint8_t start_register, const uint8_t *data, size_t len); + bool read_words_(uint8_t start_register, uint16_t *data, size_t num_words); + bool send_subcommand_(uint16_t subcommand); + bool read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len); + bool write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len); + bool wait_for_subcommand_(); + bool wait_for_cfgupdate_(); + bool enter_cfg_update_(); + bool exit_cfg_update_(); + bool regs_disabled_(bool &out); + const LogString *configure_(); + const LogString *write_config_(bool write_regs); + + bool reg_disable_{false}; + bool crc_mode_{false}; + + sensor::Sensor *stack_voltage_sensor_{nullptr}; + std::array cell_sensors_{}; + std::array temperature_sensors_{}; +}; + +} // namespace esphome::bq76972 diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml new file mode 100644 index 0000000000..42a9f633af --- /dev/null +++ b/tests/components/bq76972/common.yaml @@ -0,0 +1,59 @@ +bq76972: + id: bq76972_id + address: 0x08 + crc_enabled: true + reg_disabled: true + update_interval: 10s + stack_voltage: + name: "Pack Total Voltage" + internal_temperature: + name: "Temperature - Internal" + ts1_temperature: + name: "Temperature - Pin TS1" + ts2_temperature: + name: "Temperature - Pin TS2" + ts3_temperature: + name: "Temperature - Pin TS3" + hdq_temperature: + name: "Temperature - Pin HDQ" + cell_1_voltage: + name: "Cell 1 Voltage" + cell_2_voltage: + name: "Cell 2 Voltage" + cell_3_voltage: + name: "Cell 3 Voltage" + cell_4_voltage: + name: "Cell 4 Voltage" + cell_5_voltage: + name: "Cell 5 Voltage" + cell_6_voltage: + name: "Cell 6 Voltage" + cell_7_voltage: + name: "Cell 7 Voltage" + cell_8_voltage: + name: "Cell 8 Voltage" + cell_9_voltage: + name: "Cell 9 Voltage" + cell_10_voltage: + name: "Cell 10 Voltage" + cell_11_voltage: + name: "Cell 11 Voltage" + cell_12_voltage: + name: "Cell 12 Voltage" + cell_13_voltage: + name: "Cell 13 Voltage" + cell_14_voltage: + name: "Cell 14 Voltage" + cell_15_voltage: + name: "Cell 15 Voltage" + cell_16_voltage: + name: "Cell 16 Voltage" + +button: + - platform: template + name: "Program BQ I2C Address" + on_press: + - bq76972.program_i2c_address: + id: bq76972_id + address: 0x09 + - bq76972.program_i2c_address: !lambda "return 0x08;" diff --git a/tests/components/bq76972/test.esp32-idf.yaml b/tests/components/bq76972/test.esp32-idf.yaml new file mode 100644 index 0000000000..b0842c47a5 --- /dev/null +++ b/tests/components/bq76972/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq76972: !include common.yaml diff --git a/tests/components/bq76972/test.esp8266-ard.yaml b/tests/components/bq76972/test.esp8266-ard.yaml new file mode 100644 index 0000000000..504777ab5b --- /dev/null +++ b/tests/components/bq76972/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bq76972: !include common.yaml From 501716580043034bbf8fbd81507ecc4fbbc1cacf Mon Sep 17 00:00:00 2001 From: Sourabh Jaiswal Date: Tue, 13 Oct 2026 08:28:25 +0530 Subject: [PATCH 267/268] [selec_meter] Add EM4M (3-phase) support via model option (#17782) Co-authored-by: Claude Sonnet 5 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 --- .../components/selec_meter/selec_meter.cpp | 399 ++++++++++++++++-- esphome/components/selec_meter/selec_meter.h | 135 ++++++ .../selec_meter/selec_meter_registers.h | 133 +++++- esphome/components/selec_meter/sensor.py | 262 +++++++++++- esphome/core/defines.h | 1 + tests/components/selec_meter/common-em4m.yaml | 129 ++++++ .../selec_meter/test-em4m.esp32-idf.yaml | 8 + .../selec_meter/test-em4m.esp8266-ard.yaml | 8 + .../selec_meter/test.esp32-idf.yaml | 3 +- .../selec_meter/test.esp8266-ard.yaml | 3 +- .../selec_meter/test.rp2040-ard.yaml | 3 +- .../components/selec_meter/__init__.py | 0 .../components/selec_meter/test_sensor.py | 103 +++++ 13 files changed, 1105 insertions(+), 82 deletions(-) create mode 100644 tests/components/selec_meter/common-em4m.yaml create mode 100644 tests/components/selec_meter/test-em4m.esp32-idf.yaml create mode 100644 tests/components/selec_meter/test-em4m.esp8266-ard.yaml create mode 100644 tests/unit_tests/components/selec_meter/__init__.py create mode 100644 tests/unit_tests/components/selec_meter/test_sensor.py diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index a11cc6a008..9a895b0e0e 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -1,54 +1,358 @@ #include "selec_meter.h" #include "selec_meter_registers.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" +#include +#include +#include + namespace esphome::selec_meter { namespace helpers = modbus::helpers; ESPHOME_LOG_TAG(TAG, "selec_meter"); -static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers +static constexpr uint8_t EM2M_REGISTER_COUNT = 34; // 34 x 16-bit registers + +// Durable refusals an optional read tolerates before it is given up on +static constexpr uint8_t MAX_OPTIONAL_READ_FAILURES = 5; + +static const LogString *read_state_name(ReadState state) { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: + return LOG_STR("main block"); + case ReadState::READ_STATE_SERIAL_NUMBER: + return LOG_STR("serial number"); + case ReadState::READ_STATE_DG_SENSING: + return LOG_STR("DG sensing"); + case ReadState::READ_STATE_IDLE: + default: + return LOG_STR("idle"); + } +} + +// FP32 at `reg` in the configured word order (MSRF high word first, LSRF low word first); NaN when +// `reg` is not wholly inside `registers` +static float read_float(std::span registers, uint16_t start_address, uint16_t reg, bool word_swapped) { + auto value = word_swapped ? helpers::value_at(registers, start_address, reg) + : helpers::value_at(registers, start_address, reg); + return value.value_or(NAN); +} + +// Publishes the field at `reg` (x1000 when `kilo`) unless it decodes to NaN/Inf, e.g. an unloaded +// phase's power factor; a non-finite field is skipped on its own so the others still publish. +static void publish_float(sensor::Sensor *sens, std::span registers, uint16_t reg, bool kilo, + bool word_swapped) { + float value = read_float(registers, 0, reg, word_swapped); + if (!std::isfinite(value)) { + ESP_LOGD(TAG, "Skipping non-finite value for '%s'", sens->get_name().c_str()); + return; + } + sens->publish_state(kilo ? value * 1000.0f : value); +} + +#if defined(USE_SELEC_METER_EM4M) && (defined(USE_TEXT_SENSOR) || defined(USE_BINARY_SENSOR)) +// Counts one failure of an optional read; callers only run while `failures` is below the limit +static void note_optional_read_failure(uint8_t &failures, const LogString *name) { + if (++failures == MAX_OPTIONAL_READ_FAILURES) { + ESP_LOGW(TAG, "Reading %s failed %u times in a row, giving up", LOG_STR_ARG(name), failures); + } +} +#endif + +SelecMeter::ReadRange SelecMeter::read_range_(ReadState state) const { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: +#ifdef USE_SELEC_METER_EM4M + if (this->model_ == Model::MODEL_EM4M) + return {0, EM4M_REGISTER_COUNT}; +#endif + return {0, EM2M_REGISTER_COUNT}; + case ReadState::READ_STATE_SERIAL_NUMBER: + return {EM4M_SERIAL_NUMBER, 2}; + case ReadState::READ_STATE_DG_SENSING: + return {EM4M_DG_SENSING, 2}; + case ReadState::READ_STATE_IDLE: + default: + return {0, 0}; + } +} + +bool SelecMeter::wanted_(ReadState state) const { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: + return true; +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + case ReadState::READ_STATE_SERIAL_NUMBER: + return this->serial_number_sensor_ != nullptr && this->serial_number_failures_ < MAX_OPTIONAL_READ_FAILURES; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + case ReadState::READ_STATE_DG_SENSING: + return this->dg_sensing_sensor_ != nullptr && this->dg_sensing_failures_ < MAX_OPTIONAL_READ_FAILURES; +#endif + default: + return false; + } +} + +ReadState SelecMeter::next_read_state_after_(ReadState current) const { + for (auto next = static_cast(current) + 1; next < static_cast(ReadState::READ_STATE_IDLE); next++) { + if (this->wanted_(static_cast(next))) + return static_cast(next); + } + return ReadState::READ_STATE_IDLE; +} void SelecMeter::on_read_input_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) { - if (!modbus::succeeded(status)) - return; // the hub already logs exception responses - - // Publish a sensor if both of its registers are in this response; skipping absent registers keeps - // this correct for any read range, so the poll may be split into multiple requests. - // Values are 32-bit floats, low word first. - auto publish = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { - if (sensor == nullptr) - return; - if (auto value = helpers::value_at(registers, start_address, reg)) - sensor->publish_state(*value * unit); + ReadState current = this->read_state_; + if (current == ReadState::READ_STATE_IDLE) { + ESP_LOGW(TAG, "Unexpected response while idle, dropping"); + return; + } + // start_address comes from the exact request this answers, so a stale reply cannot match another read + if (start_address != this->read_range_(current).start) { + this->fail_current_read_(LOG_STR("Response does not match pending read"), /*durable=*/false); + return; + } + if (status.has_value()) { + const auto code = *status; + ESP_LOGW(TAG, "Modbus error 0x%02X while reading %s", static_cast(code), + LOG_STR_ARG(read_state_name(current))); + // Illegal function/address are durable; busy, acknowledge and the rest are transient + const bool durable = + code == modbus::ExceptionCode::ILLEGAL_FUNCTION || code == modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + this->fail_current_read_(nullptr, durable); + return; + } + switch (current) { + case ReadState::READ_STATE_MAIN_BLOCK: { +#ifdef USE_SELEC_METER_EM4M + bool ok = this->model_ == Model::MODEL_EM4M ? this->decode_em4m_(registers) : this->decode_em2m_(registers); +#else + bool ok = this->decode_em2m_(registers); +#endif + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(); + } + break; + } +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + case ReadState::READ_STATE_SERIAL_NUMBER: + this->decode_serial_number_(registers); + break; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + case ReadState::READ_STATE_DG_SENSING: + this->decode_dg_sensing_(registers); + break; +#endif + default: + break; + } + // Safe per modbus.h's callback contract: sending from inside a callback is picked up normally. + this->start_read_(this->next_read_state_after_(current)); +} + +void SelecMeter::fail_current_read_(const LogString *reason, bool durable) { + if (this->read_state_ == ReadState::READ_STATE_IDLE) { + ESP_LOGW(TAG, "Unexpected failure while idle, dropping"); + return; + } + ReadState failed_state = this->read_state_; + if (reason != nullptr) { + ESP_LOGW(TAG, "%s while reading %s", LOG_STR_ARG(reason), LOG_STR_ARG(read_state_name(failed_state))); + } + // Only the main block drives component health; an unsupported optional read is not a fault + if (failed_state == ReadState::READ_STATE_MAIN_BLOCK) + this->status_set_warning(); +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + if (failed_state == ReadState::READ_STATE_SERIAL_NUMBER && durable) + note_optional_read_failure(this->serial_number_failures_, read_state_name(failed_state)); +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + if (failed_state == ReadState::READ_STATE_DG_SENSING && durable) + note_optional_read_failure(this->dg_sensing_failures_, read_state_name(failed_state)); +#endif + this->start_read_(this->next_read_state_after_(failed_state)); +} + +bool SelecMeter::on_no_response(std::span /*request_pdu*/) { + this->fail_current_read_(LOG_STR("No Modbus response")); + return false; +} + +void SelecMeter::on_not_sent(std::span /*request_pdu*/) { + this->fail_current_read_(LOG_STR("Modbus request not sent")); +} + +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) +void SelecMeter::decode_serial_number_(std::span registers) { + auto serial = + this->word_swap_ + ? helpers::value_at(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER) + : helpers::value_at(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER); + if (!serial) { + ESP_LOGW(TAG, "Unexpected response size for serial number"); + note_optional_read_failure(this->serial_number_failures_, read_state_name(ReadState::READ_STATE_SERIAL_NUMBER)); + return; + } + char buf[9]; + snprintf(buf, sizeof(buf), "%08" PRIX32, *serial); + this->serial_number_sensor_->publish_state(buf); + // The serial number never changes, so stop reading it + this->serial_number_failures_ = MAX_OPTIONAL_READ_FAILURES; +} +#endif + +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) +void SelecMeter::decode_dg_sensing_(std::span registers) { + float value = read_float(registers, EM4M_DG_SENSING, EM4M_DG_SENSING, this->word_swap_); + if (!std::isfinite(value)) { + ESP_LOGW(TAG, "Short or non-finite DG sensing response, ignoring"); + note_optional_read_failure(this->dg_sensing_failures_, read_state_name(ReadState::READ_STATE_DG_SENSING)); + return; + } + this->dg_sensing_sensor_->publish_state(value != 0); + this->dg_sensing_failures_ = 0; +} +#endif + +bool SelecMeter::publish_fields_(std::span registers, std::span fields, + uint8_t min_count) { + if (registers.size() < min_count) { + ESP_LOGW(TAG, "Short response: %u registers", static_cast(registers.size())); + return false; + } + for (const auto &entry : fields) { + FloatField field; + progmem_memcpy(&field, &entry, sizeof(field)); + sensor::Sensor *sens = this->*field.sensor; + if (sens != nullptr) + publish_float(sens, registers, field.reg, field.kilo, this->word_swap_); + } + return true; +} + +bool SelecMeter::decode_em2m_(std::span registers) { + static constexpr FloatField EM2M_FIELDS[] PROGMEM = { + {&SelecMeter::total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, false}, + {&SelecMeter::import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, false}, + {&SelecMeter::import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::apparent_energy_sensor_, SELEC_APPARENT_ENERGY, false}, + {&SelecMeter::active_power_sensor_, SELEC_ACTIVE_POWER, true}, + {&SelecMeter::reactive_power_sensor_, SELEC_REACTIVE_POWER, true}, + {&SelecMeter::apparent_power_sensor_, SELEC_APPARENT_POWER, true}, + {&SelecMeter::voltage_sensor_, SELEC_VOLTAGE, false}, + {&SelecMeter::current_sensor_, SELEC_CURRENT, false}, + {&SelecMeter::power_factor_sensor_, SELEC_POWER_FACTOR, false}, + {&SelecMeter::frequency_sensor_, SELEC_FREQUENCY, false}, + {&SelecMeter::maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, true}, }; + return this->publish_fields_(registers, EM2M_FIELDS, EM2M_REGISTER_COUNT); +} + +#ifdef USE_SELEC_METER_EM4M +bool SelecMeter::decode_em4m_(std::span registers) { + static constexpr FloatField EM4M_FIELDS[] PROGMEM = { + // Common quantities, shared sensor keys with EM2M + {&SelecMeter::voltage_sensor_, EM4M_VOLTAGE, false}, + {&SelecMeter::current_sensor_, EM4M_CURRENT, false}, + {&SelecMeter::active_power_sensor_, EM4M_ACTIVE_POWER, true}, + {&SelecMeter::reactive_power_sensor_, EM4M_REACTIVE_POWER, true}, + {&SelecMeter::apparent_power_sensor_, EM4M_APPARENT_POWER, true}, + {&SelecMeter::power_factor_sensor_, EM4M_POWER_FACTOR, false}, + {&SelecMeter::frequency_sensor_, EM4M_FREQUENCY, false}, + {&SelecMeter::maximum_demand_active_power_sensor_, EM4M_MAXIMUM_DEMAND_ACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_reactive_power_sensor_, EM4M_MAXIMUM_DEMAND_REACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_apparent_power_sensor_, EM4M_MAXIMUM_DEMAND_APPARENT_POWER, true}, + {&SelecMeter::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY, false}, + // Line-to-line and net (mains/DG) quantities have no per-phase equivalent + {&SelecMeter::voltage_l12_sensor_, EM4M_VOLTAGE_L12, false}, + {&SelecMeter::voltage_l23_sensor_, EM4M_VOLTAGE_L23, false}, + {&SelecMeter::voltage_l31_sensor_, EM4M_VOLTAGE_L31, false}, + {&SelecMeter::average_voltage_ll_sensor_, EM4M_AVERAGE_VOLTAGE_LL, false}, + {&SelecMeter::net_active_energy_mains_sensor_, EM4M_NET_ACTIVE_ENERGY_MAINS, false}, + {&SelecMeter::net_reactive_energy_mains_sensor_, EM4M_NET_REACTIVE_ENERGY_MAINS, false}, + {&SelecMeter::net_apparent_energy_mains_sensor_, EM4M_NET_APPARENT_ENERGY_MAINS, false}, + {&SelecMeter::net_active_energy_dg_sensor_, EM4M_NET_ACTIVE_ENERGY_DG, false}, + {&SelecMeter::net_reactive_energy_dg_sensor_, EM4M_NET_REACTIVE_ENERGY_DG, false}, + {&SelecMeter::net_apparent_energy_dg_sensor_, EM4M_NET_APPARENT_ENERGY_DG, false}, + }; + + // reg_l1 is the L1 register; L2/L3 follow EM4M_PHASE_STRIDE apart + struct PhaseField { + sensor::Sensor *Phase::*sensor; + uint16_t reg_l1; + bool kilo; + }; + static constexpr PhaseField PHASE_FIELDS[] PROGMEM = { + {&Phase::voltage_sensor_, EM4M_VOLTAGE_L1, false}, + {&Phase::current_sensor_, EM4M_CURRENT_L1, false}, + {&Phase::active_power_sensor_, EM4M_ACTIVE_POWER_L1, true}, + {&Phase::reactive_power_sensor_, EM4M_REACTIVE_POWER_L1, true}, + {&Phase::apparent_power_sensor_, EM4M_APPARENT_POWER_L1, true}, + {&Phase::power_factor_sensor_, EM4M_POWER_FACTOR_L1, false}, + {&Phase::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY_L1, false}, + {&Phase::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY_L1, false}, + {&Phase::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY_L1, false}, + {&Phase::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY_L1, false}, + {&Phase::apparent_energy_sensor_, EM4M_APPARENT_ENERGY_L1, false}, + }; + + if (!this->publish_fields_(registers, EM4M_FIELDS, EM4M_REGISTER_COUNT)) + return false; + for (const auto &entry : PHASE_FIELDS) { + PhaseField field; + progmem_memcpy(&field, &entry, sizeof(field)); + for (uint8_t phase = 0; phase < 3; phase++) { + sensor::Sensor *sens = this->phases_[phase].*field.sensor; + if (sens != nullptr) + publish_float(sens, registers, field.reg_l1 + phase * EM4M_PHASE_STRIDE, field.kilo, this->word_swap_); + } + } + return true; +} +#endif + +void SelecMeter::update() { + if (this->read_state_ != ReadState::READ_STATE_IDLE) { + ESP_LOGD(TAG, "Skipping update: previous read cycle (%s) still in progress", + LOG_STR_ARG(read_state_name(this->read_state_))); + return; + } + this->start_read_(ReadState::READ_STATE_MAIN_BLOCK); +} - publish(this->total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->apparent_energy_sensor_, SELEC_APPARENT_ENERGY, NO_DEC_UNIT); - publish(this->active_power_sensor_, SELEC_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->reactive_power_sensor_, SELEC_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->apparent_power_sensor_, SELEC_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->voltage_sensor_, SELEC_VOLTAGE, NO_DEC_UNIT); - publish(this->current_sensor_, SELEC_CURRENT, NO_DEC_UNIT); - publish(this->power_factor_sensor_, SELEC_POWER_FACTOR, NO_DEC_UNIT); - publish(this->frequency_sensor_, SELEC_FREQUENCY, NO_DEC_UNIT); - publish(this->maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT); +void SelecMeter::start_read_(ReadState state) { + this->read_state_ = state; + if (state == ReadState::READ_STATE_IDLE) + return; + const ReadRange range = this->read_range_(state); + // A refused send gets no callback, so unwind here like the async failure paths + if (!this->read_input_registers(range.start, range.count)) + this->fail_current_read_(LOG_STR("Modbus request refused")); } -void SelecMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } void SelecMeter::dump_config() { ESP_LOGCONFIG(TAG, "SELEC Meter:\n" + " Model: %s\n" + " Byte Order: %s\n" " Address: 0x%02X", + this->model_ == Model::MODEL_EM4M ? LOG_STR_LITERAL("EM4M") : LOG_STR_LITERAL("EM2M"), + this->word_swap_ ? LOG_STR_LITERAL("little_endian (LSRF)") : LOG_STR_LITERAL("big_endian (MSRF)"), this->address_); LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_); LOG_SENSOR(" ", "Import Active Energy", this->import_active_energy_sensor_); @@ -67,6 +371,41 @@ void SelecMeter::dump_config() { LOG_SENSOR(" ", "Maximum Demand Active Power", this->maximum_demand_active_power_sensor_); LOG_SENSOR(" ", "Maximum Demand Reactive Power", this->maximum_demand_reactive_power_sensor_); LOG_SENSOR(" ", "Maximum Demand Apparent Power", this->maximum_demand_apparent_power_sensor_); +#ifdef USE_SELEC_METER_EM4M + if (this->model_ == Model::MODEL_EM4M) { + LOG_SENSOR(" ", "Voltage L1-L2", this->voltage_l12_sensor_); + LOG_SENSOR(" ", "Voltage L2-L3", this->voltage_l23_sensor_); + LOG_SENSOR(" ", "Voltage L3-L1", this->voltage_l31_sensor_); + LOG_SENSOR(" ", "Average Voltage LL", this->average_voltage_ll_sensor_); + for (uint8_t phase = 0; phase < 3; phase++) { + const auto &p = this->phases_[phase]; + ESP_LOGCONFIG(TAG, " Phase %c", 'A' + phase); + LOG_SENSOR(" ", "Voltage", p.voltage_sensor_); + LOG_SENSOR(" ", "Current", p.current_sensor_); + LOG_SENSOR(" ", "Active Power", p.active_power_sensor_); + LOG_SENSOR(" ", "Reactive Power", p.reactive_power_sensor_); + LOG_SENSOR(" ", "Apparent Power", p.apparent_power_sensor_); + LOG_SENSOR(" ", "Power Factor", p.power_factor_sensor_); + LOG_SENSOR(" ", "Import Active Energy", p.import_active_energy_sensor_); + LOG_SENSOR(" ", "Export Active Energy", p.export_active_energy_sensor_); + LOG_SENSOR(" ", "Import Reactive Energy", p.import_reactive_energy_sensor_); + LOG_SENSOR(" ", "Export Reactive Energy", p.export_reactive_energy_sensor_); + LOG_SENSOR(" ", "Apparent Energy", p.apparent_energy_sensor_); + } + LOG_SENSOR(" ", "Net Active Energy (Mains)", this->net_active_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Reactive Energy (Mains)", this->net_reactive_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Apparent Energy (Mains)", this->net_apparent_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Active Energy (DG)", this->net_active_energy_dg_sensor_); + LOG_SENSOR(" ", "Net Reactive Energy (DG)", this->net_reactive_energy_dg_sensor_); + LOG_SENSOR(" ", "Net Apparent Energy (DG)", this->net_apparent_energy_dg_sensor_); + } +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "DG Sensing", this->dg_sensing_sensor_); +#endif +#endif } } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 470242c918..98fea2987e 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -1,8 +1,15 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/log.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include @@ -15,6 +22,24 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } +// Per-phase sensor in the Phase struct, setter indexed by phase (0 = L1/A, 1 = L2/B, 2 = L3/C) +#define SELEC_METER_PHASE_SENSOR(name) \ + void set_##name##_sensor(uint8_t phase, sensor::Sensor *(name)) { this->phases_[phase].name##_sensor_ = name; } + +enum class Model : uint8_t { + MODEL_EM2M, + MODEL_EM4M, +}; + +// Read cycle steps in the order they run. The extra reads (serial number, DG sensing) sit far outside +// the main block and get their own transaction. +enum class ReadState : uint8_t { + READ_STATE_MAIN_BLOCK, + READ_STATE_SERIAL_NUMBER, + READ_STATE_DG_SENSING, + READ_STATE_IDLE, +}; + class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: SELEC_METER_SENSOR(total_active_energy) @@ -35,12 +60,122 @@ class SelecMeter final : public PollingComponent, public modbus::ModbusClientDev SELEC_METER_SENSOR(maximum_demand_reactive_power) SELEC_METER_SENSOR(maximum_demand_apparent_power) +#ifdef USE_SELEC_METER_EM4M + // EM4M per-phase sensors, indexed by phase (see Phase below) + SELEC_METER_PHASE_SENSOR(voltage) + SELEC_METER_PHASE_SENSOR(current) + SELEC_METER_PHASE_SENSOR(active_power) + SELEC_METER_PHASE_SENSOR(reactive_power) + SELEC_METER_PHASE_SENSOR(apparent_power) + SELEC_METER_PHASE_SENSOR(power_factor) + SELEC_METER_PHASE_SENSOR(import_active_energy) + SELEC_METER_PHASE_SENSOR(export_active_energy) + SELEC_METER_PHASE_SENSOR(import_reactive_energy) + SELEC_METER_PHASE_SENSOR(export_reactive_energy) + SELEC_METER_PHASE_SENSOR(apparent_energy) + + // Line-to-line and aggregate sensors, not tied to one phase + SELEC_METER_SENSOR(voltage_l12) + SELEC_METER_SENSOR(voltage_l23) + SELEC_METER_SENSOR(voltage_l31) + SELEC_METER_SENSOR(average_voltage_ll) + SELEC_METER_SENSOR(net_active_energy_mains) + SELEC_METER_SENSOR(net_reactive_energy_mains) + SELEC_METER_SENSOR(net_apparent_energy_mains) + SELEC_METER_SENSOR(net_active_energy_dg) + SELEC_METER_SENSOR(net_reactive_energy_dg) + SELEC_METER_SENSOR(net_apparent_energy_dg) +#endif + + void set_model(Model model) { this->model_ = model; } + // true: registers are word-swapped (LSRF / Mid Little Endian, CDAB byte order) + // false: registers are in the meter's straightforward order (MSRF / Big Endian, ABCD byte order) + void set_word_swap(bool word_swap) { this->word_swap_ = word_swap; } +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + void set_serial_number_sensor(text_sensor::TextSensor *serial_number) { this->serial_number_sensor_ = serial_number; } +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + void set_dg_sensing_sensor(binary_sensor::BinarySensor *dg_sensing) { this->dg_sensing_sensor_ = dg_sensing; } +#endif + void update() override; void on_read_input_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override; + bool on_no_response(std::span request_pdu) override; + void on_not_sent(std::span request_pdu) override; void dump_config() override; + + protected: + // One float field of the main block: target sensor, register and whether it is published x1000 + struct FloatField { + sensor::Sensor *SelecMeter::*sensor; + uint16_t reg; + bool kilo; + }; + + // Publishes each configured field in `fields` (a PROGMEM table); false on a payload shorter than + // `min_count`, and the caller sets or clears the warning from the result + bool publish_fields_(std::span registers, std::span fields, uint8_t min_count); + bool decode_em2m_(std::span registers); +#ifdef USE_SELEC_METER_EM4M + bool decode_em4m_(std::span registers); +#ifdef USE_TEXT_SENSOR + void decode_serial_number_(std::span registers); +#endif +#ifdef USE_BINARY_SENSOR + void decode_dg_sensing_(std::span registers); +#endif +#endif + // First register and register count of the read for `state` + struct ReadRange { + uint16_t start; + uint8_t count; + }; + ReadRange read_range_(ReadState state) const; + // Whether the read cycle should run `state` on this pass + bool wanted_(ReadState state) const; + // Next wanted step after `current`, so one failed range does not cancel the other reads + ReadState next_read_state_after_(ReadState current) const; + // Sends the read for `state`; safe from a callback per the modbus callback contract + void start_read_(ReadState state); + // Clears the pending read and advances the chain. Only a MAIN_BLOCK failure sets the warning and + // only a `durable` refusal counts toward MAX_OPTIONAL_READ_FAILURES; `reason` nullptr means already logged + void fail_current_read_(const LogString *reason, bool durable = false); + +#ifdef USE_SELEC_METER_EM4M + // phases_[0..2] are L1/L2/L3 (A/B/C), EM4M_PHASE_STRIDE registers apart + struct Phase { + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; + sensor::Sensor *active_power_sensor_{nullptr}; + sensor::Sensor *reactive_power_sensor_{nullptr}; + sensor::Sensor *apparent_power_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_{nullptr}; + sensor::Sensor *import_active_energy_sensor_{nullptr}; + sensor::Sensor *export_active_energy_sensor_{nullptr}; + sensor::Sensor *import_reactive_energy_sensor_{nullptr}; + sensor::Sensor *export_reactive_energy_sensor_{nullptr}; + sensor::Sensor *apparent_energy_sensor_{nullptr}; + }; + Phase phases_[3]; +#endif + + Model model_{Model::MODEL_EM2M}; + bool word_swap_{false}; + ReadState read_state_{ReadState::READ_STATE_IDLE}; + + // An optional read stops once its counter reaches MAX_OPTIONAL_READ_FAILURES +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + text_sensor::TextSensor *serial_number_sensor_{nullptr}; + // Also set to the limit once the serial number is published, since it never changes + uint8_t serial_number_failures_{0}; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + binary_sensor::BinarySensor *dg_sensing_sensor_{nullptr}; + uint8_t dg_sensing_failures_{0}; +#endif }; } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter_registers.h b/esphome/components/selec_meter/selec_meter_registers.h index d299560aab..e7678ccabe 100644 --- a/esphome/components/selec_meter/selec_meter_registers.h +++ b/esphome/components/selec_meter/selec_meter_registers.h @@ -1,30 +1,117 @@ #pragma once +#include + namespace esphome::selec_meter { -static const float TWO_DEC_UNIT = 0.01; -static const float ONE_DEC_UNIT = 0.1; -static const float NO_DEC_UNIT = 1; -static const float MULTIPLY_TEN_UNIT = 10; -static const float MULTIPLY_THOUSAND_UNIT = 1000; - /* PHASE STATUS REGISTERS */ -static const uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000; -static const uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002; -static const uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004; -static const uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006; -static const uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008; -static const uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A; -static const uint16_t SELEC_APPARENT_ENERGY = 0x000C; -static const uint16_t SELEC_ACTIVE_POWER = 0x000E; -static const uint16_t SELEC_REACTIVE_POWER = 0x0010; -static const uint16_t SELEC_APPARENT_POWER = 0x0012; -static const uint16_t SELEC_VOLTAGE = 0x0014; -static const uint16_t SELEC_CURRENT = 0x0016; -static const uint16_t SELEC_POWER_FACTOR = 0x0018; -static const uint16_t SELEC_FREQUENCY = 0x001A; -static const uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; -static const uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; -static const uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; +static constexpr uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000; +static constexpr uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002; +static constexpr uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004; +static constexpr uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006; +static constexpr uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008; +static constexpr uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A; +static constexpr uint16_t SELEC_APPARENT_ENERGY = 0x000C; +static constexpr uint16_t SELEC_ACTIVE_POWER = 0x000E; +static constexpr uint16_t SELEC_REACTIVE_POWER = 0x0010; +static constexpr uint16_t SELEC_APPARENT_POWER = 0x0012; +static constexpr uint16_t SELEC_VOLTAGE = 0x0014; +static constexpr uint16_t SELEC_CURRENT = 0x0016; +static constexpr uint16_t SELEC_POWER_FACTOR = 0x0018; +static constexpr uint16_t SELEC_FREQUENCY = 0x001A; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; + +/* EM4M REGISTERS -- offsets are register indices from input register 30000 */ +static constexpr uint8_t EM4M_REGISTER_COUNT = 114; // 114 x 16-bit registers + +/* EM4M -- common quantities shared with the EM2M sensor keys */ +static constexpr uint16_t EM4M_VOLTAGE = 0x0006; // Average Voltage LN +static constexpr uint16_t EM4M_CURRENT = 0x0016; // Average Current +static constexpr uint16_t EM4M_ACTIVE_POWER = 0x002A; // Total kW +static constexpr uint16_t EM4M_REACTIVE_POWER = 0x002C; // Total kVAr +static constexpr uint16_t EM4M_APPARENT_POWER = 0x002E; // Total kVA +static constexpr uint16_t EM4M_POWER_FACTOR = 0x0036; // Average PF +static constexpr uint16_t EM4M_FREQUENCY = 0x0038; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_ACTIVE_POWER = 0x0046; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_REACTIVE_POWER = 0x0048; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_APPARENT_POWER = 0x004A; +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY = 0x0058; // Total kWh - Import +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY = 0x005A; // Total kWh - Export +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY = 0x0068; // Total kVArh - Import +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY = 0x006A; // Total kVArh - Export +static constexpr uint16_t EM4M_AVERAGE_VOLTAGE_LL = 0x000E; // Average Voltage LL + +// Net (import - export) energy totals, split by source. Only meaningful when the +// meter's "Dual Source" setting is Yes; DG registers read 0 on single-source installs. +static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_MAINS = 0x003A; // Total Net kWh (MAINS) +static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_MAINS = 0x003C; // Total Net kVArh (MAINS) +static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_MAINS = 0x003E; // Total Net kVAh (MAINS) +static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_DG = 0x0040; // Total Net kWh (DG) +static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_DG = 0x0042; // Total Net kVArh (DG) +static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_DG = 0x0044; // Total Net kVAh (DG) + +/* EM4M -- per-phase quantities, no EM2M equivalent */ +static constexpr uint16_t EM4M_VOLTAGE_L1 = 0x0000; // Voltage V1N +static constexpr uint16_t EM4M_VOLTAGE_L2 = 0x0002; // Voltage V2N +static constexpr uint16_t EM4M_VOLTAGE_L3 = 0x0004; // Voltage V3N +static constexpr uint16_t EM4M_VOLTAGE_L12 = 0x0008; // Voltage V12 +static constexpr uint16_t EM4M_VOLTAGE_L23 = 0x000A; // Voltage V23 +static constexpr uint16_t EM4M_VOLTAGE_L31 = 0x000C; // Voltage V31 +static constexpr uint16_t EM4M_CURRENT_L1 = 0x0010; +static constexpr uint16_t EM4M_CURRENT_L2 = 0x0012; +static constexpr uint16_t EM4M_CURRENT_L3 = 0x0014; +static constexpr uint16_t EM4M_ACTIVE_POWER_L1 = 0x0018; // kW1 +static constexpr uint16_t EM4M_ACTIVE_POWER_L2 = 0x001A; // kW2 +static constexpr uint16_t EM4M_ACTIVE_POWER_L3 = 0x001C; // kW3 +static constexpr uint16_t EM4M_REACTIVE_POWER_L1 = 0x001E; // kVAr1 +static constexpr uint16_t EM4M_REACTIVE_POWER_L2 = 0x0020; // kVAr2 +static constexpr uint16_t EM4M_REACTIVE_POWER_L3 = 0x0022; // kVAr3 +static constexpr uint16_t EM4M_APPARENT_POWER_L1 = 0x0024; // kVA1 +static constexpr uint16_t EM4M_APPARENT_POWER_L2 = 0x0026; // kVA2 +static constexpr uint16_t EM4M_APPARENT_POWER_L3 = 0x0028; // kVA3 +static constexpr uint16_t EM4M_POWER_FACTOR_L1 = 0x0030; +static constexpr uint16_t EM4M_POWER_FACTOR_L2 = 0x0032; +static constexpr uint16_t EM4M_POWER_FACTOR_L3 = 0x0034; +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L1 = 0x004C; // kWh1 - Import +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L2 = 0x004E; // kWh2 - Import +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L3 = 0x0050; // kWh3 - Import +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L1 = 0x0052; // kWh1 - Export +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L2 = 0x0054; // kWh2 - Export +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L3 = 0x0056; // kWh3 - Export +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L1 = 0x005C; // kVArh1 - Import +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L2 = 0x005E; // kVArh2 - Import +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L3 = 0x0060; // kVArh3 - Import +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L1 = 0x0062; // kVArh1 - Export +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L2 = 0x0064; // kVArh2 - Export +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L3 = 0x0066; // kVArh3 - Export +static constexpr uint16_t EM4M_APPARENT_ENERGY_L1 = 0x006C; // kVAh-1 +static constexpr uint16_t EM4M_APPARENT_ENERGY_L2 = 0x006E; // kVAh-2 +static constexpr uint16_t EM4M_APPARENT_ENERGY_L3 = 0x0070; // kVAh-3 + +// The decoder reads L2/L3 as the L1 register plus one or two strides; the L2/L3 names above +// document the datasheet and are checked against that stride here. +static constexpr uint16_t EM4M_PHASE_STRIDE = 2; +#define SELEC_EM4M_CHECK_STRIDE(name) \ + static_assert(EM4M_##name##_L2 == EM4M_##name##_L1 + EM4M_PHASE_STRIDE && \ + EM4M_##name##_L3 == EM4M_##name##_L1 + 2 * EM4M_PHASE_STRIDE, \ + #name " per-phase registers must be EM4M_PHASE_STRIDE apart"); +SELEC_EM4M_CHECK_STRIDE(VOLTAGE) +SELEC_EM4M_CHECK_STRIDE(CURRENT) +SELEC_EM4M_CHECK_STRIDE(ACTIVE_POWER) +SELEC_EM4M_CHECK_STRIDE(REACTIVE_POWER) +SELEC_EM4M_CHECK_STRIDE(APPARENT_POWER) +SELEC_EM4M_CHECK_STRIDE(POWER_FACTOR) +SELEC_EM4M_CHECK_STRIDE(IMPORT_ACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(EXPORT_ACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(IMPORT_REACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(EXPORT_REACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(APPARENT_ENERGY) +#undef SELEC_EM4M_CHECK_STRIDE + +// Far outside the main 0-113 register block; read via a separate Modbus transaction. +static constexpr uint16_t EM4M_SERIAL_NUMBER = 0x02AC; // Serial No. (Hex), 30684 +static constexpr uint16_t EM4M_DG_SENSING = 0x02C6; // DG Sensing (1: Pass, 0: Fail), 30710 } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 04aef00d46..5e6709ec97 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -1,5 +1,11 @@ import esphome.codegen as cg -from esphome.components import modbus, sensor +from esphome.components import binary_sensor, modbus, sensor, text_sensor +from esphome.components.const import ( + BYTE_ORDER_BIG, + BYTE_ORDER_LITTLE, + CONF_BYTE_ORDER, + CONF_SERIAL_NUMBER, +) import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE_POWER, @@ -11,6 +17,10 @@ from esphome.const import ( CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, + CONF_MODEL, + CONF_PHASE_A, + CONF_PHASE_B, + CONF_PHASE_C, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, CONF_VOLTAGE, @@ -24,6 +34,7 @@ from esphome.const import ( DEVICE_CLASS_VOLTAGE, ICON_CURRENT_AC, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_HERTZ, @@ -37,9 +48,22 @@ from esphome.const import ( ) from esphome.types import ConfigType -AUTO_LOAD = ["modbus"] CODEOWNERS = ["@sourabhjaiswal"] +CONF_DG_SENSING = "dg_sensing" + + +def AUTO_LOAD(config: ConfigType) -> list[str]: + return ["modbus"] + [ + component + for key, component in ( + (CONF_SERIAL_NUMBER, "text_sensor"), + (CONF_DG_SENSING, "binary_sensor"), + ) + if not config or key in config + ] + + CONF_TOTAL_ACTIVE_ENERGY = "total_active_energy" CONF_TOTAL_REACTIVE_ENERGY = "total_reactive_energy" CONF_APPARENT_ENERGY = "apparent_energy" @@ -47,19 +71,68 @@ CONF_MAXIMUM_DEMAND_ACTIVE_POWER = "maximum_demand_active_power" CONF_MAXIMUM_DEMAND_REACTIVE_POWER = "maximum_demand_reactive_power" CONF_MAXIMUM_DEMAND_APPARENT_POWER = "maximum_demand_apparent_power" +CONF_VOLTAGE_L12 = "voltage_l12" +CONF_VOLTAGE_L23 = "voltage_l23" +CONF_VOLTAGE_L31 = "voltage_l31" + +CONF_AVERAGE_VOLTAGE_LL = "average_voltage_ll" +CONF_NET_ACTIVE_ENERGY_MAINS = "net_active_energy_mains" +CONF_NET_REACTIVE_ENERGY_MAINS = "net_reactive_energy_mains" +CONF_NET_APPARENT_ENERGY_MAINS = "net_apparent_energy_mains" +CONF_NET_ACTIVE_ENERGY_DG = "net_active_energy_dg" +CONF_NET_REACTIVE_ENERGY_DG = "net_reactive_energy_dg" +CONF_NET_APPARENT_ENERGY_DG = "net_apparent_energy_dg" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( "SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice ) +SelecMeterModel = selec_meter_ns.enum("Model", is_class=True) -SENSORS = { +MODEL_EM2M = "em2m" +MODEL_EM4M = "em4m" +MODELS = { + MODEL_EM2M: SelecMeterModel.MODEL_EM2M, + MODEL_EM4M: SelecMeterModel.MODEL_EM4M, +} + +# Register word order of the FP32 values: big_endian sends the high word first (ABCD), +# little_endian the low word first (CDAB). msrf/lsrf are aliases matching the meter's own +# register 40070 (Endianness Selection) menu names. +BYTE_ORDER_MSRF = "msrf" +BYTE_ORDER_LSRF = "lsrf" +# Maps the YAML option directly to the C++ word_swap bool -- no enum needed on the C++ side. +BYTE_ORDER_WORD_SWAP = { + BYTE_ORDER_BIG: False, + BYTE_ORDER_LITTLE: True, + BYTE_ORDER_MSRF: False, + BYTE_ORDER_LSRF: True, +} +validate_byte_order = cv.enum(BYTE_ORDER_WORD_SWAP, lower=True) + +# EM2M-only: total combined energy figures with no non-DG equivalent on the EM4M. +EM2M_SENSORS = { CONF_TOTAL_ACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=2, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_APPARENT_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +} + +# Sensors available on both EM2M and EM4M. On EM4M these read the aggregate +# (Total/Average) registers rather than a single-phase measurement. +COMMON_SENSORS = { CONF_IMPORT_ACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=2, @@ -72,11 +145,6 @@ SENSORS = { device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), - CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema( - unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, - accuracy_decimals=2, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), CONF_IMPORT_REACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=2, @@ -87,11 +155,6 @@ SENSORS = { accuracy_decimals=2, state_class=STATE_CLASS_TOTAL_INCREASING, ), - CONF_APPARENT_ENERGY: sensor.sensor_schema( - unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, - accuracy_decimals=2, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), CONF_ACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, accuracy_decimals=3, @@ -154,13 +217,115 @@ SENSORS = { ), } -CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}) - .extend( - {cv.Optional(sensor_name): schema for sensor_name, schema in SENSORS.items()} + +def _sensor_group(*keys: str, **schema_kwargs) -> dict[str, cv.Schema]: + """One identical sensor_schema() per key.""" + return {key: sensor.sensor_schema(**schema_kwargs) for key in keys} + + +# EM4M-only, genuinely per-phase quantities, nested under phase_a/phase_b/phase_c like +# sdm_meter/atm90e32 do for 3-phase meters. +PHASE_SENSORS = { + key: {**COMMON_SENSORS, **EM2M_SENSORS}[key] + for key in ( + CONF_VOLTAGE, + CONF_CURRENT, + CONF_ACTIVE_POWER, + CONF_REACTIVE_POWER, + CONF_APPARENT_POWER, + CONF_POWER_FACTOR, + CONF_IMPORT_ACTIVE_ENERGY, + CONF_EXPORT_ACTIVE_ENERGY, + CONF_IMPORT_REACTIVE_ENERGY, + CONF_EXPORT_REACTIVE_ENERGY, + CONF_APPARENT_ENERGY, ) - .extend(cv.polling_component_schema("10s")) - .extend(modbus.modbus_device_schema(0x01)) +} + +PHASE_SCHEMA = cv.Schema( + {cv.Optional(key): schema for key, schema in PHASE_SENSORS.items()} +) + +# EM4M-only, line-to-line and mains/DG net totals -- these don't map to a single phase, so they +# stay flat rather than living under phase_a/phase_b/phase_c. +EM4M_SENSORS = { + **_sensor_group( + CONF_VOLTAGE_L12, + CONF_VOLTAGE_L23, + CONF_VOLTAGE_L31, + CONF_AVERAGE_VOLTAGE_LL, + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + # Net (import - export) energy totals, split by source. Only meaningful when the meter's + # "Dual Source" setting is Yes; DG registers read 0 on single-source installs. + **_sensor_group( + CONF_NET_ACTIVE_ENERGY_MAINS, + CONF_NET_ACTIVE_ENERGY_DG, + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL, + ), + **_sensor_group( + CONF_NET_REACTIVE_ENERGY_MAINS, + CONF_NET_REACTIVE_ENERGY_DG, + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL, + ), + **_sensor_group( + CONF_NET_APPARENT_ENERGY_MAINS, + CONF_NET_APPARENT_ENERGY_DG, + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL, + ), +} + +PHASES = (CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C) + + +def _optional(sensors: dict[str, cv.Schema]) -> dict[cv.Optional, cv.Schema]: + return {cv.Optional(key): schema for key, schema in sensors.items()} + + +_BASE_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}).extend( + cv.polling_component_schema("10s"), modbus.modbus_device_schema(0x01) +) + +# EM2M keeps this component's original word-swapped decode; EM4M uses the meter's factory +# default (MSRF / Big Endian), confirmed against real hardware. +CONFIG_SCHEMA = cv.typed_schema( + { + MODEL_EM2M: _BASE_SCHEMA.extend( + { + cv.Optional( + CONF_BYTE_ORDER, default=BYTE_ORDER_LITTLE + ): validate_byte_order, + **_optional(COMMON_SENSORS), + **_optional(EM2M_SENSORS), + } + ), + MODEL_EM4M: _BASE_SCHEMA.extend( + { + cv.Optional( + CONF_BYTE_ORDER, default=BYTE_ORDER_BIG + ): validate_byte_order, + **_optional(COMMON_SENSORS), + **_optional(EM4M_SENSORS), + **{cv.Optional(phase): PHASE_SCHEMA for phase in PHASES}, + cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema(), + cv.Optional(CONF_DG_SENSING): binary_sensor.binary_sensor_schema(), + } + ), + }, + key=CONF_MODEL, + default_type=MODEL_EM2M, + enum=MODELS, + lower=True, ) @@ -175,7 +340,58 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - for name in SENSORS: - if name in config: - sens = await sensor.new_sensor(config[name]) - cg.add(getattr(var, f"set_{name}_sensor")(sens)) + cg.add(var.set_model(config[CONF_MODEL])) + cg.add(var.set_word_swap(config[CONF_BYTE_ORDER])) + if config[CONF_MODEL] == MODEL_EM4M: + cg.add_define("USE_SELEC_METER_EM4M") + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor) + await sensors(CONF_IMPORT_ACTIVE_ENERGY, var.set_import_active_energy_sensor) + await sensors(CONF_EXPORT_ACTIVE_ENERGY, var.set_export_active_energy_sensor) + await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor) + await sensors(CONF_IMPORT_REACTIVE_ENERGY, var.set_import_reactive_energy_sensor) + await sensors(CONF_EXPORT_REACTIVE_ENERGY, var.set_export_reactive_energy_sensor) + await sensors(CONF_APPARENT_ENERGY, var.set_apparent_energy_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_active_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors( + CONF_MAXIMUM_DEMAND_ACTIVE_POWER, var.set_maximum_demand_active_power_sensor + ) + await sensors( + CONF_MAXIMUM_DEMAND_REACTIVE_POWER, var.set_maximum_demand_reactive_power_sensor + ) + await sensors( + CONF_MAXIMUM_DEMAND_APPARENT_POWER, var.set_maximum_demand_apparent_power_sensor + ) + await sensors(CONF_VOLTAGE_L12, var.set_voltage_l12_sensor) + await sensors(CONF_VOLTAGE_L23, var.set_voltage_l23_sensor) + await sensors(CONF_VOLTAGE_L31, var.set_voltage_l31_sensor) + await sensors(CONF_AVERAGE_VOLTAGE_LL, var.set_average_voltage_ll_sensor) + await sensors(CONF_NET_ACTIVE_ENERGY_MAINS, var.set_net_active_energy_mains_sensor) + await sensors( + CONF_NET_REACTIVE_ENERGY_MAINS, var.set_net_reactive_energy_mains_sensor + ) + await sensors( + CONF_NET_APPARENT_ENERGY_MAINS, var.set_net_apparent_energy_mains_sensor + ) + await sensors(CONF_NET_ACTIVE_ENERGY_DG, var.set_net_active_energy_dg_sensor) + await sensors(CONF_NET_REACTIVE_ENERGY_DG, var.set_net_reactive_energy_dg_sensor) + await sensors(CONF_NET_APPARENT_ENERGY_DG, var.set_net_apparent_energy_dg_sensor) + for phase_index, phase in enumerate(PHASES): + if (phase_config := config.get(phase)) is None: + continue + for name in PHASE_SENSORS: + if name in phase_config: + sens = await sensor.new_sensor(phase_config[name]) + cg.add(getattr(var, f"set_{name}_sensor")(phase_index, sens)) + await text_sensor.sub_text_sensors(config)( + CONF_SERIAL_NUMBER, var.set_serial_number_sensor + ) + await binary_sensor.sub_binary_sensors(config)( + CONF_DG_SENSING, var.set_dg_sensing_sensor + ) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fc6dd82ed9..a7d2084a54 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -251,6 +251,7 @@ #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT +#define USE_SELEC_METER_EM4M #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY diff --git a/tests/components/selec_meter/common-em4m.yaml b/tests/components/selec_meter/common-em4m.yaml new file mode 100644 index 0000000000..793e3c30a6 --- /dev/null +++ b/tests/components/selec_meter/common-em4m.yaml @@ -0,0 +1,129 @@ +sensor: + - platform: selec_meter + modbus_id: modbus_bus + model: em4m + byte_order: little_endian + phase_a: + voltage: + name: SelecEM4M Voltage L1 + current: + name: SelecEM4M Current L1 + active_power: + name: SelecEM4M Active Power L1 + reactive_power: + name: SelecEM4M Reactive Power L1 + apparent_power: + name: SelecEM4M Apparent Power L1 + power_factor: + name: SelecEM4M Power Factor L1 + import_active_energy: + name: SelecEM4M Import Active Energy L1 + export_active_energy: + name: SelecEM4M Export Active Energy L1 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L1 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L1 + apparent_energy: + name: SelecEM4M Apparent Energy L1 + phase_b: + voltage: + name: SelecEM4M Voltage L2 + current: + name: SelecEM4M Current L2 + active_power: + name: SelecEM4M Active Power L2 + reactive_power: + name: SelecEM4M Reactive Power L2 + apparent_power: + name: SelecEM4M Apparent Power L2 + power_factor: + name: SelecEM4M Power Factor L2 + import_active_energy: + name: SelecEM4M Import Active Energy L2 + export_active_energy: + name: SelecEM4M Export Active Energy L2 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L2 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L2 + apparent_energy: + name: SelecEM4M Apparent Energy L2 + phase_c: + voltage: + name: SelecEM4M Voltage L3 + current: + name: SelecEM4M Current L3 + active_power: + name: SelecEM4M Active Power L3 + reactive_power: + name: SelecEM4M Reactive Power L3 + apparent_power: + name: SelecEM4M Apparent Power L3 + power_factor: + name: SelecEM4M Power Factor L3 + import_active_energy: + name: SelecEM4M Import Active Energy L3 + export_active_energy: + name: SelecEM4M Export Active Energy L3 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L3 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L3 + apparent_energy: + name: SelecEM4M Apparent Energy L3 + voltage_l12: + name: SelecEM4M Voltage L12 + voltage_l23: + name: SelecEM4M Voltage L23 + voltage_l31: + name: SelecEM4M Voltage L31 + average_voltage_ll: + name: SelecEM4M Average Voltage LL + active_power: + name: SelecEM4M Active Power + reactive_power: + name: SelecEM4M Reactive Power + apparent_power: + name: SelecEM4M Apparent Power + voltage: + name: SelecEM4M Average Voltage + current: + name: SelecEM4M Average Current + power_factor: + name: SelecEM4M Average Power Factor + frequency: + name: SelecEM4M Frequency + import_active_energy: + name: SelecEM4M Total Import Active Energy + export_active_energy: + name: SelecEM4M Total Export Active Energy + import_reactive_energy: + name: SelecEM4M Total Import Reactive Energy + export_reactive_energy: + name: SelecEM4M Total Export Reactive Energy + net_active_energy_mains: + name: SelecEM4M Net Active Energy Mains + net_reactive_energy_mains: + name: SelecEM4M Net Reactive Energy Mains + net_apparent_energy_mains: + name: SelecEM4M Net Apparent Energy Mains + net_active_energy_dg: + name: SelecEM4M Net Active Energy DG + net_reactive_energy_dg: + name: SelecEM4M Net Reactive Energy DG + net_apparent_energy_dg: + name: SelecEM4M Net Apparent Energy DG + maximum_demand_active_power: + name: SelecEM4M Maximum Demand Active Power + disabled_by_default: true + maximum_demand_reactive_power: + name: SelecEM4M Maximum Demand Reactive Power + disabled_by_default: true + maximum_demand_apparent_power: + name: SelecEM4M Maximum Demand Apparent Power + disabled_by_default: true + serial_number: + name: SelecEM4M Serial Number + dg_sensing: + name: SelecEM4M DG Sensing diff --git a/tests/components/selec_meter/test-em4m.esp32-idf.yaml b/tests/components/selec_meter/test-em4m.esp32-idf.yaml new file mode 100644 index 0000000000..56c7c38bf9 --- /dev/null +++ b/tests/components/selec_meter/test-em4m.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + flow_control_pin: GPIO26 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + selec_meter: !include common-em4m.yaml diff --git a/tests/components/selec_meter/test-em4m.esp8266-ard.yaml b/tests/components/selec_meter/test-em4m.esp8266-ard.yaml new file mode 100644 index 0000000000..7cdabad7d6 --- /dev/null +++ b/tests/components/selec_meter/test-em4m.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + flow_control_pin: GPIO15 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + selec_meter: !include common-em4m.yaml diff --git a/tests/components/selec_meter/test.esp32-idf.yaml b/tests/components/selec_meter/test.esp32-idf.yaml index 4df9f5863e..3c588d0afc 100644 --- a/tests/components/selec_meter/test.esp32-idf.yaml +++ b/tests/components/selec_meter/test.esp32-idf.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO26 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/selec_meter/test.esp8266-ard.yaml b/tests/components/selec_meter/test.esp8266-ard.yaml index 6daa08c22b..6f4125b47b 100644 --- a/tests/components/selec_meter/test.esp8266-ard.yaml +++ b/tests/components/selec_meter/test.esp8266-ard.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO15 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml - -<<: !include common.yaml diff --git a/tests/components/selec_meter/test.rp2040-ard.yaml b/tests/components/selec_meter/test.rp2040-ard.yaml index a65500ccc3..687622481e 100644 --- a/tests/components/selec_meter/test.rp2040-ard.yaml +++ b/tests/components/selec_meter/test.rp2040-ard.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO6 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml - -<<: !include common.yaml diff --git a/tests/unit_tests/components/selec_meter/__init__.py b/tests/unit_tests/components/selec_meter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/selec_meter/test_sensor.py b/tests/unit_tests/components/selec_meter/test_sensor.py new file mode 100644 index 0000000000..b4b14cd7e1 --- /dev/null +++ b/tests/unit_tests/components/selec_meter/test_sensor.py @@ -0,0 +1,103 @@ +"""Tests for the selec_meter configuration schema.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.selec_meter import sensor as selec_meter +import esphome.config_validation as cv +from esphome.types import ConfigType + + +def _validate(config: dict[str, Any]) -> ConfigType: + return selec_meter.CONFIG_SCHEMA(config) + + +def test_model_defaults_to_em2m() -> None: + config = _validate({"voltage": {"name": "Voltage"}}) + assert config["model"] == selec_meter.MODEL_EM2M + + +def test_model_is_case_insensitive() -> None: + config = _validate({"model": "EM4M"}) + assert config["model"] == selec_meter.MODEL_EM4M + + +def test_model_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid, match="Unknown value 'em9m'"): + _validate({"model": "em9m"}) + + +@pytest.mark.parametrize( + ("model", "key", "value"), + [ + ("em2m", "voltage_l12", {"name": "Test"}), + ("em2m", "serial_number", {"name": "Test"}), + ("em2m", "dg_sensing", {"name": "Test"}), + ("em2m", "phase_a", {"voltage": {"name": "Voltage"}}), + ("em2m", "phase_b", {"voltage": {"name": "Voltage"}}), + ("em2m", "phase_c", {"voltage": {"name": "Voltage"}}), + ("em4m", "total_active_energy", {"name": "Test"}), + ("em4m", "total_reactive_energy", {"name": "Test"}), + ("em4m", "apparent_energy", {"name": "Test"}), + ], +) +def test_key_rejected_on_wrong_model(model: str, key: str, value: Any) -> None: + with pytest.raises(cv.Invalid, match="extra keys not allowed") as err: + _validate({"model": model, key: value}) + assert err.value.path == [key] + + +def test_em4m_only_key_rejected_on_default_model() -> None: + with pytest.raises(cv.Invalid, match="extra keys not allowed"): + _validate({"voltage_l12": {"name": "Test"}}) + + +def test_em4m_with_phases_accepted() -> None: + config = _validate( + { + "model": "em4m", + "phase_a": {"voltage": {"name": "Voltage L1"}}, + "phase_c": {"power_factor": {"name": "PF L3"}}, + } + ) + assert "voltage" in config["phase_a"] + assert "power_factor" in config["phase_c"] + + +@pytest.mark.parametrize( + ("model", "byte_order", "word_swap"), + [ + ("em2m", "little_endian", True), + ("em4m", "big_endian", False), + ], +) +def test_byte_order_default_per_model( + model: str, byte_order: str, word_swap: bool +) -> None: + config = _validate({"model": model}) + assert config["byte_order"] == byte_order + assert config["byte_order"].enum_value is word_swap + + +@pytest.mark.parametrize( + ("model", "byte_order", "word_swap"), + [ + ("em2m", "big_endian", False), + ("em4m", "LITTLE_ENDIAN", True), + ("em4m", "lsrf", True), + ("em2m", "msrf", False), + ], +) +def test_byte_order_explicit_override( + model: str, byte_order: str, word_swap: bool +) -> None: + config = _validate({"model": model, "byte_order": byte_order}) + assert config["byte_order"].enum_value is word_swap + + +def test_byte_order_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid, match="Unknown value"): + _validate({"byte_order": "middle_endian"}) From e289a944516584213c1adf8a7b3e5eb92a1181ce Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 23:11:21 -0400 Subject: [PATCH 268/268] [friedrich] New Climate IR component (#9178) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/friedrich/__init__.py | 1 + esphome/components/friedrich/climate.py | 32 +++ esphome/components/friedrich/friedrich.cpp | 234 ++++++++++++++++++ esphome/components/friedrich/friedrich.h | 47 ++++ tests/components/friedrich/__init__.py | 15 ++ tests/components/friedrich/common.yaml | 5 + tests/components/friedrich/friedrich_test.cpp | 215 ++++++++++++++++ .../components/friedrich/test.esp32-idf.yaml | 4 + .../friedrich/test.esp8266-ard.yaml | 4 + 10 files changed, 558 insertions(+) create mode 100644 esphome/components/friedrich/__init__.py create mode 100644 esphome/components/friedrich/climate.py create mode 100644 esphome/components/friedrich/friedrich.cpp create mode 100644 esphome/components/friedrich/friedrich.h create mode 100644 tests/components/friedrich/__init__.py create mode 100644 tests/components/friedrich/common.yaml create mode 100644 tests/components/friedrich/friedrich_test.cpp create mode 100644 tests/components/friedrich/test.esp32-idf.yaml create mode 100644 tests/components/friedrich/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f068a3a221..fc14cb33d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -214,6 +214,7 @@ esphome/components/feedback/* @ianchi esphome/components/file/* @esphome/core esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core +esphome/components/friedrich/* @rwrozelle esphome/components/fs3000/* @kahrendt esphome/components/ft5x06/* @clydebarrow esphome/components/ft63x6/* @gpambrozio diff --git a/esphome/components/friedrich/__init__.py b/esphome/components/friedrich/__init__.py new file mode 100644 index 0000000000..09f1d1df4f --- /dev/null +++ b/esphome/components/friedrich/__init__.py @@ -0,0 +1 @@ +DOMAIN = "friedrich" diff --git a/esphome/components/friedrich/climate.py b/esphome/components/friedrich/climate.py new file mode 100644 index 0000000000..52f1f35708 --- /dev/null +++ b/esphome/components/friedrich/climate.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_MODEL, CONF_SENSOR +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@rwrozelle"] + +friedrich_ns = cg.esphome_ns.namespace("friedrich") +FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR) + +Model = friedrich_ns.enum("Model", is_class=True) +MODELS = { + "MW12Y3H": Model.MODEL_MW12Y3H, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend( + { + cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True), + # The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it + cv.Optional(CONF_SENSOR): cv.invalid( + "'sensor' is not supported by the Friedrich climate component" + ), + } +) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("aeha") # used from C++ + var = await climate_ir.new_climate_ir(config) + cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/friedrich/friedrich.cpp b/esphome/components/friedrich/friedrich.cpp new file mode 100644 index 0000000000..b1fcfe0615 --- /dev/null +++ b/esphome/components/friedrich/friedrich.cpp @@ -0,0 +1,234 @@ +#include "friedrich.h" + +#include + +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::friedrich { +// clang-format off +/* +* MW12Y3H - Currently the only supported model. Model field is retained to allow future per-model code differentiation. +* AEHA +* Byte0 Fixed = 0x00 +* Byte1 Fixed = 0x08 +* Byte2 Fixed = 0x08 +* Byte3 Command: Move Louver = 0x36 Off = 0x40, else Fixed = 0x7F +* Byte4 Checksum when command, else Fixed = 0x90 +* Byte5 N/A when command, else Fixed = 0x0C +* Byte6 N/A when command, else Temp(F), add 0x80 for On +* Byte7 N/A when command, else Mode (Min Heat = 0xD0 and Coil Dry = 0x10 are not exposed) +* Byte8 N/A when command, else Fan, add 0x08 for Vertical Swing +* Byte9 N/A when command, else Fixed = 0x00 // not interested +* Byte10 N/A when command, else Fixed = 0x00 // not interested +* Byte11 N/A when command, else Fixed = 0x00 // not interested +* Byte12 N/A when command, else Economy (On = 0x00, Off = 0x04) +* Byte13 N/A when command, else CheckSum +*/ +// clang-format on +ESPHOME_LOG_TAG(TAG, "friedrich"); + +static constexpr uint8_t TEMP_MIN_NOT_HEAT = 64; +static constexpr uint8_t TEMP_DEFAULT = 72; +static constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH; +static constexpr uint8_t STATE_MESSAGE_LENGTH_UTIL = 5; +static constexpr uint16_t CARRIER_ADDRESS = 0x28C6; +static constexpr uint32_t CARRIER_FREQUENCY = 38000; + +static constexpr uint8_t BYTE3_POWER_OFF = 0x40; +static constexpr uint8_t BYTE6_POWER_ON = 0x80; +static constexpr uint8_t BYTE7_MODE_AUTO = 0x00; +static constexpr uint8_t BYTE8_FAN_AUTO = 0x00; +static constexpr uint8_t BYTE8_FAN_SWING = 0x08; + +// Every state frame: power on, economy off; bytes 6, 7, 8 and 13 are patched per state +static constexpr uint8_t STATE_TEMPLATE[STATE_MESSAGE_LENGTH] PROGMEM = { + 0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, BYTE6_POWER_ON, BYTE7_MODE_AUTO, BYTE8_FAN_AUTO, 0x00, 0x00, 0x00, 0x04, 0x00}; +// The off command, including its checksum (255 - byte 3) +static constexpr uint8_t OFF_FRAME[STATE_MESSAGE_LENGTH_UTIL] PROGMEM = {0x00, 0x08, 0x08, BYTE3_POWER_OFF, + 255 - BYTE3_POWER_OFF}; + +struct Code { + uint8_t value; // a climate enum, all of which are uint8_t + uint8_t code; +}; +static constexpr Code MODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, BYTE7_MODE_AUTO}, + {climate::CLIMATE_MODE_COOL, 0x80}, + {climate::CLIMATE_MODE_DRY, 0x40}, + {climate::CLIMATE_MODE_FAN_ONLY, 0xC0}, + {climate::CLIMATE_MODE_HEAT, 0x20}, +}; +static constexpr Code FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, BYTE8_FAN_AUTO}, {climate::CLIMATE_FAN_HIGH, 0x80}, + {climate::CLIMATE_FAN_MEDIUM, 0x40}, {climate::CLIMATE_FAN_LOW, 0xC0}, + {climate::CLIMATE_FAN_QUIET, 0x20}, +}; + +// The entry whose value (or code) matches key. Entries are copied out of PROGMEM. +template static optional find_entry(const Code (&table)[N], uint8_t key, bool match_code) { + for (const Code &slot : table) { + Code entry; + progmem_memcpy(&entry, &slot, sizeof(entry)); + if ((match_code ? entry.code : entry.value) == key) + return entry; + } + return {}; +} + +// The temperature code is the 4-bit reversal of (F - TEMP_MIN) / TEMP_STEP; the mapping is its own inverse. +static uint8_t reverse_nibble(uint8_t value) { return reverse_bits(value) >> 4; } + +static bool matches_template(const uint8_t *data, uint8_t from, uint8_t to) { + for (uint8_t i = from; i <= to; i++) { + if (data[i] != progmem_read_byte(&STATE_TEMPLATE[i])) + return false; + } + return true; +} + +/* + * https://gist.github.com/GeorgeDewar/11171561 + * 1. Reverse (flip) bytes 6 - 13) + * 2. Sum those bytes + * 3. (208 - sum) % 256 + * 4. Reverse (flip) bytes of result + */ +static uint8_t checksum_state(const uint8_t *data) { + uint8_t sum = 0; + for (uint8_t i = 6; i < STATE_MESSAGE_LENGTH - 1; ++i) { + sum += reverse_bits(data[i]); + } + return reverse_bits(static_cast(208 - sum)); +} + +static const LogString *model_to_string(Model model) { + switch (model) { + case Model::MODEL_MW12Y3H: + default: + return LOG_STR("MW12Y3H"); + } +} + +climate::ClimateTraits FriedrichClimate::traits() { + auto traits = ClimateIR::traits(); + traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT); + return traits; +} + +void FriedrichClimate::setup() { + ClimateIR::setup(); + // The base class defaults assume Celsius. This also discards a value saved in Celsius by an earlier version. + if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN) { + this->target_temperature = TEMP_DEFAULT; + } + this->target_temperature = std::min(this->target_temperature, TEMP_MAX); +} + +void FriedrichClimate::dump_config() { + // ClimateIR::dump_config() is not called: it prints the temperature range in Celsius + LOG_CLIMATE("", "Friedrich Climate", this); + ESP_LOGCONFIG(TAG, + " Temperature range: %u to %u °F\n" + " Model: %s", + TEMP_MIN, TEMP_MAX, LOG_STR_ARG(model_to_string(this->model_))); +} + +void FriedrichClimate::transmit_state() { + if (this->mode == climate::CLIMATE_MODE_OFF) { + this->transmit_off_(); + return; + } + + ESP_LOGV(TAG, "Transmit state"); + + // Snap to what the unit accepts (even values, 64°F minimum unless heating) and store it, + // so the state published after this call matches the frame sent. + const uint8_t effective_min = this->mode == climate::CLIMATE_MODE_HEAT ? TEMP_MIN : TEMP_MIN_NOT_HEAT; + uint8_t temperature = (uint8_t) roundf(clamp(this->target_temperature, effective_min, TEMP_MAX)); + temperature += temperature % 2; + this->target_temperature = temperature; + + const auto mode = find_entry(MODES, this->mode, false); + const auto fan = find_entry(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO), false); + + uint8_t frame[STATE_MESSAGE_LENGTH]; + progmem_memcpy(frame, STATE_TEMPLATE, sizeof(frame)); + frame[6] |= reverse_nibble((temperature - TEMP_MIN) / TEMP_STEP); + frame[7] = mode.has_value() ? mode->code : BYTE7_MODE_AUTO; + frame[8] = fan.has_value() ? fan->code : BYTE8_FAN_AUTO; + if (this->swing_mode != climate::CLIMATE_SWING_OFF) + frame[8] |= BYTE8_FAN_SWING; + frame[STATE_MESSAGE_LENGTH - 1] = checksum_state(frame); + + this->transmit_(frame, sizeof(frame)); +} + +void FriedrichClimate::transmit_off_() { + ESP_LOGV(TAG, "Transmit off"); + + uint8_t frame[STATE_MESSAGE_LENGTH_UTIL]; + progmem_memcpy(frame, OFF_FRAME, sizeof(frame)); + this->transmit_(frame, sizeof(frame)); +} + +void FriedrichClimate::transmit_(const uint8_t *data, uint8_t len) { + ESP_LOGV(TAG, "Transmit message"); + + auto transmit = this->transmitter_->transmit(); + auto *dst = transmit.get_data(); + dst->set_carrier_frequency(CARRIER_FREQUENCY); + remote_base::AEHAProtocol().encode(dst, CARRIER_ADDRESS, data, len); + transmit.perform(); +} + +bool FriedrichClimate::on_receive(remote_base::RemoteReceiveData src) { + ESP_LOGV(TAG, "Received message"); + const auto odata = remote_base::AEHAProtocol().decode(src); + if (!odata.has_value()) + return false; + const auto &d = odata->data; + // Other vendors also use AEHA; only accept frames with our address and fixed header bytes. + if (odata->address != CARRIER_ADDRESS || d.size() < STATE_MESSAGE_LENGTH_UTIL || !matches_template(d.data(), 0, 2)) + return false; + + if (d.size() == STATE_MESSAGE_LENGTH_UTIL) { + // Not looking for other types of messages + if (d[3] != BYTE3_POWER_OFF || d[4] != 255 - d[3]) + return false; + ESP_LOGV(TAG, "Received off message"); + this->mode = climate::CLIMATE_MODE_OFF; + this->publish_state(); + return true; + } + + if (d.size() != STATE_MESSAGE_LENGTH || d[STATE_MESSAGE_LENGTH - 1] != checksum_state(d.data()) || + !matches_template(d.data(), 3, 5)) + return false; + + // Ignore frames with a mode this component does not expose (e.g. MIN_HEAT, COIL_DRY) + const auto mode = find_entry(MODES, d[7], true); + if (!mode.has_value()) { + ESP_LOGV(TAG, "Ignoring unknown mode 0x%02X", d[7]); + return false; + } + this->mode = static_cast(mode->value); + + // State frames always carry the power-on flag in bit 7 (power off is the separate 5-byte frame), so it is + // stripped rather than acted on + const uint8_t temp_code = d[6] & ~BYTE6_POWER_ON; + const uint8_t temp_index = reverse_nibble(temp_code); + if (temp_code <= 0x0F && temp_index <= (TEMP_MAX - TEMP_MIN) / TEMP_STEP) + this->target_temperature = TEMP_MIN + temp_index * TEMP_STEP; + + this->swing_mode = (d[8] & BYTE8_FAN_SWING) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + const auto fan = find_entry(FANS, d[8] & ~BYTE8_FAN_SWING, true); + this->fan_mode = fan.has_value() ? static_cast(fan->value) : climate::CLIMATE_FAN_AUTO; + + this->publish_state(); + return true; +} + +} // namespace esphome::friedrich diff --git a/esphome/components/friedrich/friedrich.h b/esphome/components/friedrich/friedrich.h new file mode 100644 index 0000000000..3a0be00f00 --- /dev/null +++ b/esphome/components/friedrich/friedrich.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::friedrich { + +// Extension point for other units: add new models here and branch on model_ where they differ. +// The default must stay MW12Y3H so existing configurations do not change. +enum class Model : uint8_t { + MODEL_MW12Y3H, // MW12Y3H built from a remote that only provided Fahrenheit +}; + +class FriedrichClimate : public climate_ir::ClimateIR { + public: + // The unit reports temperatures in Fahrenheit (see traits()); the unit only accepts even values. + static constexpr uint8_t TEMP_MIN = 60; // actually 64 for anything but heating + static constexpr uint8_t TEMP_MAX = 88; + static constexpr uint8_t TEMP_STEP = 2; + static constexpr uint8_t STATE_MESSAGE_LENGTH = 14; + + FriedrichClimate() + : ClimateIR(TEMP_MIN, TEMP_MAX, TEMP_STEP, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_QUIET}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + + void set_model(Model model) { this->model_ = model; } + + protected: + void setup() override; + void dump_config() override; + climate::ClimateTraits traits() override; + /// Transmit via IR the state of this climate controller. + void transmit_state() override; + /// Transmit via IR power off command. + void transmit_off_(); + + /// Parse incoming message + bool on_receive(remote_base::RemoteReceiveData src) override; + + /// Transmit message as IR pulses + void transmit_(const uint8_t *data, uint8_t len); + + Model model_{Model::MODEL_MW12Y3H}; +}; + +} // namespace esphome::friedrich diff --git a/tests/components/friedrich/__init__.py b/tests/components/friedrich/__init__.py new file mode 100644 index 0000000000..3028881357 --- /dev/null +++ b/tests/components/friedrich/__init__.py @@ -0,0 +1,15 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + from esphome.components.remote_base import request_protocol + + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # The AEHA source is compiled only behind its define; climate.py requests it in the real build. + request_protocol("aeha") + + manifest.to_code = to_code_testing diff --git a/tests/components/friedrich/common.yaml b/tests/components/friedrich/common.yaml new file mode 100644 index 0000000000..e56be394c5 --- /dev/null +++ b/tests/components/friedrich/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: friedrich + name: Friedrich Climate + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp new file mode 100644 index 0000000000..610eca9c09 --- /dev/null +++ b/tests/components/friedrich/friedrich_test.cpp @@ -0,0 +1,215 @@ +#include +#include + +#include "esphome/components/friedrich/friedrich.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/helpers.h" + +namespace esphome::friedrich::testing { + +using remote_base::AEHAData; +using remote_base::AEHAProtocol; + +// Records the last transmission instead of sending it +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + int sends{0}; + remote_base::RawTimings last; + + protected: + void send_internal(uint32_t, uint32_t) override { + this->sends++; + this->last = this->temp_.get_data(); + } +}; + +class TestFriedrich : public FriedrichClimate { + public: + using FriedrichClimate::on_receive; + using FriedrichClimate::setup; + using FriedrichClimate::traits; + using FriedrichClimate::transmit_state; +}; + +constexpr uint16_t ADDRESS = 0x28C6; +constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH; +// Byte 6 holds the temperature code (bit 7 is the power flag), byte 7 the mode, byte 8 the fan +constexpr uint8_t TEMP_72_CODE = 0x06; + +struct Fixture { + TestFriedrich sut; + CapturingTransmitter transmitter; + + Fixture() { this->sut.set_transmitter(&this->transmitter); } + + // The last transmission decoded, or an empty frame if it could not be decoded + AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); } + + bool receive(const AEHAData &frame) { + remote_base::RemoteTransmitData tx; + AEHAProtocol().encode(&tx, frame); + return this->sut.on_receive(rx_of(tx.get_data())); + } + + static remote_base::RemoteReceiveData rx_of(const remote_base::RawTimings &timings) { + return remote_base::RemoteReceiveData(timings, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + } +}; + +// Independent implementation of the 14 byte frame checksum +static AEHAData state_frame(uint8_t temp_code, uint8_t mode, uint8_t fan = 0x00) { + AEHAData f{ADDRESS, {0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, (uint8_t) (temp_code | 0x80), mode, fan, 0, 0, 0, 0x04, 0}}; + uint8_t sum = 0; + for (int i = 6; i < 13; i++) + sum += reverse_bits(f.data[i]); + f.data[13] = reverse_bits(static_cast(208 - sum)); + return f; +} + +static AEHAData off_frame() { return AEHAData{ADDRESS, {0x00, 0x08, 0x08, 0x40, 0xBF}}; } + +TEST(FriedrichTests, TraitsReportFahrenheit) { + Fixture f; + auto traits = f.sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 60.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 2.0f); +} + +TEST(FriedrichTests, OffIsAlwaysSentAsFiveByteFrame) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_OFF; + f.sut.transmit_state(); + ASSERT_EQ(f.transmitter.sends, 1); + EXPECT_EQ(f.sent(), off_frame()); +} + +TEST(FriedrichTests, StateFrameEncodesFahrenheitTarget) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 72; + f.sut.fan_mode = climate::CLIMATE_FAN_AUTO; + f.sut.swing_mode = climate::CLIMATE_SWING_OFF; + f.sut.transmit_state(); + EXPECT_EQ(f.sent(), state_frame(TEMP_72_CODE, 0x80)); +} + +TEST(FriedrichTests, OddTargetStepsUpToEven) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 71; + f.sut.transmit_state(); + auto data = f.sent(); + ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH); + EXPECT_EQ(data.data[6], TEMP_72_CODE | 0x80); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); // stored value matches what was sent +} + +// Codes captured from the remote +TEST(FriedrichTests, EveryTemperatureEncodesToRemoteCode) { + static constexpr uint8_t CODES[] = {0x00, 0x08, 0x04, 0x0C, 0x02, 0x0A, 0x06, 0x0E, + 0x01, 0x09, 0x05, 0x0D, 0x03, 0x0B, 0x07}; + for (size_t i = 0; i < sizeof(CODES); i++) { + const auto fahrenheit = static_cast(60 + i * 2); + SCOPED_TRACE(fahrenheit); + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_HEAT; + f.sut.target_temperature = fahrenheit; + f.sut.transmit_state(); + EXPECT_EQ(f.sent(), state_frame(CODES[i], 0x20)); + + Fixture g; + EXPECT_TRUE(g.receive(state_frame(CODES[i], 0x20))); + EXPECT_FLOAT_EQ(g.sut.target_temperature, fahrenheit); + } +} + +TEST(FriedrichTests, NonHeatModesAreRaisedToSixtyFour) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 60; + f.sut.transmit_state(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 64.0f); + auto data = f.sent(); + ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH); + EXPECT_EQ(data.data[6], 0x04 | 0x80); // 64 F + + f.sut.mode = climate::CLIMATE_MODE_HEAT; + f.sut.target_temperature = 60; + f.sut.transmit_state(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 60.0f); +} + +TEST(FriedrichTests, ReceivesStateFrame) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(TEMP_72_CODE, 0x20, 0x80))); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); + EXPECT_EQ(f.sut.fan_mode, climate::CLIMATE_FAN_HIGH); +} + +TEST(FriedrichTests, ReceivesOffFrame) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + EXPECT_TRUE(f.receive(off_frame())); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); +} + +TEST(FriedrichTests, IgnoresUnknownModeWithoutChangingState) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 80; + EXPECT_FALSE(f.receive(state_frame(TEMP_72_CODE, 0xD0))); // MIN_HEAT is not exposed + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 80.0f); +} + +TEST(FriedrichTests, IgnoresCorruptedFrames) { + struct Case { + const char *name; + AEHAData frame; + std::function corrupt; + }; + const Case cases[] = { + {"other address", off_frame(), [](AEHAData &f) { f.address = 0x1234; }}, + {"bad header byte", off_frame(), [](AEHAData &f) { f.data[1] = 0x09; }}, + {"bad off checksum", off_frame(), [](AEHAData &f) { f.data[4] ^= 0xFF; }}, + {"bad state checksum", state_frame(TEMP_72_CODE, 0x20), [](AEHAData &f) { f.data[13] ^= 0xFF; }}, + }; + for (const auto &c : cases) { + SCOPED_TRACE(c.name); + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + AEHAData frame = c.frame; + c.corrupt(frame); + EXPECT_FALSE(f.receive(frame)); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL); + } +} + +TEST(FriedrichTests, OwnTransmissionIsAccepted) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_FAN_ONLY; + f.sut.target_temperature = 76; + f.sut.fan_mode = climate::CLIMATE_FAN_LOW; + f.sut.swing_mode = climate::CLIMATE_SWING_VERTICAL; + f.sut.transmit_state(); + + Fixture g; + EXPECT_TRUE(g.sut.on_receive(Fixture::rx_of(f.transmitter.last))); + EXPECT_EQ(g.sut.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(g.sut.target_temperature, 76.0f); + EXPECT_EQ(g.sut.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(g.sut.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(FriedrichTests, SetupDefaultsTargetToSeventyTwoFahrenheit) { + Fixture f; + f.sut.setup(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); +} + +} // namespace esphome::friedrich::testing diff --git a/tests/components/friedrich/test.esp32-idf.yaml b/tests/components/friedrich/test.esp32-idf.yaml new file mode 100644 index 0000000000..bee1962597 --- /dev/null +++ b/tests/components/friedrich/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + friedrich: !include common.yaml diff --git a/tests/components/friedrich/test.esp8266-ard.yaml b/tests/components/friedrich/test.esp8266-ard.yaml new file mode 100644 index 0000000000..d58d801494 --- /dev/null +++ b/tests/components/friedrich/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + friedrich: !include common.yaml